Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 6 additions & 5 deletions src/browser/components/ArchivedWorkspaces/ArchivedWorkspaces.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import type {
WorkspaceRemovalDescendant,
} from "@/common/types/workspace";
import { getErrorMessage } from "@/common/utils/errors";
import { useAPI } from "@/browser/contexts/API";
import { useAPI, useLiveAPI } from "@/browser/contexts/API";
import { useWorkspaceContext } from "@/browser/contexts/WorkspaceContext";
import { usePersistedState } from "@/browser/hooks/usePersistedState";
import { usePopoverError } from "@/browser/hooks/usePopoverError";
Expand Down Expand Up @@ -334,6 +334,7 @@ export const ArchivedWorkspaces: React.FC<ArchivedWorkspacesProps> = ({

const { unarchiveWorkspace, removeWorkspace, setSelectedWorkspace } = useWorkspaceContext();
const { api } = useAPI();
const liveApi = useLiveAPI();
const [searchQuery, setSearchQuery] = React.useState("");
const [processingIds, setProcessingIds] = React.useState<Set<string>>(new Set());
const [deleteWorktreeIds, setDeleteWorktreeIds] = React.useState<Set<string>>(new Set());
Expand Down Expand Up @@ -626,7 +627,7 @@ export const ArchivedWorkspaces: React.FC<ArchivedWorkspacesProps> = ({
};

const handleBulkDeleteWorktree = async () => {
if (!api) {
if (!liveApi) {
return;
}

Expand Down Expand Up @@ -654,7 +655,7 @@ export const ArchivedWorkspaces: React.FC<ArchivedWorkspacesProps> = ({
setDeleteWorktreeIds((prev) => new Set(prev).add(id));

try {
const result = await api.workspace.deleteWorktree({ workspaceId: id });
const result = await liveApi.workspace.deleteWorktree({ workspaceId: id });
if (!result.success) {
setBulkOperation((prev) =>
prev
Expand Down Expand Up @@ -701,15 +702,15 @@ export const ArchivedWorkspaces: React.FC<ArchivedWorkspacesProps> = ({
const rect = anchorEl?.getBoundingClientRect();
const anchor = rect ? { top: rect.top + window.scrollY, left: rect.right + 10 } : undefined;

if (!api) {
if (!liveApi) {
deleteWorktreeError.showError(workspaceId, "Not connected to server", anchor);
Comment thread
ThomasK33 marked this conversation as resolved.
return;
}

setProcessingIds((prev) => new Set(prev).add(workspaceId));
setDeleteWorktreeIds((prev) => new Set(prev).add(workspaceId));
try {
const result = await api.workspace.deleteWorktree({ workspaceId });
const result = await liveApi.workspace.deleteWorktree({ workspaceId });
if (result.success) {
onWorkspacesChanged?.();
return;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import { Checkbox } from "../Checkbox/Checkbox";
import { Input } from "@/browser/components/Input/Input";
import type { FileTreeNode } from "@/common/utils/git/numstatParser";
import { DirectoryTree } from "../DirectoryTree/DirectoryTree";
import { useAPI } from "@/browser/contexts/API";
import { useLiveAPI } from "@/browser/contexts/API";
import { formatKeybind, isMac } from "@/browser/utils/ui/keybinds";
import { getErrorMessage } from "@/common/utils/errors";

Expand All @@ -31,7 +31,7 @@ export const DirectoryPickerModal: React.FC<DirectoryPickerModalProps> = ({
onClose,
onSelectPath,
}) => {
const { api } = useAPI();
const liveApi = useLiveAPI();
Comment thread
ThomasK33 marked this conversation as resolved.
const [root, setRoot] = useState<FileTreeNode | null>(null);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
Expand All @@ -45,7 +45,7 @@ export const DirectoryPickerModal: React.FC<DirectoryPickerModalProps> = ({

const loadDirectory = useCallback(
async (path: string) => {
if (!api) {
if (!liveApi) {
setError("Not connected to server");
return;
}
Expand All @@ -54,7 +54,7 @@ export const DirectoryPickerModal: React.FC<DirectoryPickerModalProps> = ({
setCanCreateFolder(false);

try {
const result = await api.general.listDirectory({ path });
const result = await liveApi.general.listDirectory({ path });

if (!result.success) {
const errorMessage = typeof result.error === "string" ? result.error : "Unknown error";
Expand Down Expand Up @@ -82,7 +82,7 @@ export const DirectoryPickerModal: React.FC<DirectoryPickerModalProps> = ({
setIsLoading(false);
}
},
[api]
[liveApi]
Comment thread
ThomasK33 marked this conversation as resolved.
);

// Sync pathInput with initialPath when modal opens (component stays mounted)
Expand Down Expand Up @@ -110,13 +110,16 @@ export const DirectoryPickerModal: React.FC<DirectoryPickerModalProps> = ({

// If user has typed a different path, try to load it first
if (trimmedInput && trimmedInput !== root?.path) {
if (!api) return;
if (!liveApi) {
setError("Not connected to server");
return;
}
setIsLoading(true);
setError(null);
setCanCreateFolder(false);

try {
const result = await api.general.listDirectory({ path: trimmedInput });
const result = await liveApi.general.listDirectory({ path: trimmedInput });
if (!result.success) {
const errorMessage = typeof result.error === "string" ? result.error : "Unknown error";
const isNotFound =
Expand Down Expand Up @@ -150,17 +153,21 @@ export const DirectoryPickerModal: React.FC<DirectoryPickerModalProps> = ({

onSelectPath(root.path);
onClose();
}, [onClose, onSelectPath, root, pathInput, api]);
}, [onClose, onSelectPath, root, pathInput, liveApi]);

const handleCreateFolder = useCallback(async () => {
const trimmedPath = pathInput.trim();
if (!trimmedPath || !api) return;
if (!trimmedPath) return;
if (!liveApi) {
setError("Not connected to server");
return;
}

setIsLoading(true);
setError(null);

try {
const createResult = await api.general.createDirectory({ path: trimmedPath });
const createResult = await liveApi.general.createDirectory({ path: trimmedPath });
if (!createResult.success) {
setError(createResult.error ?? "Failed to create folder");
setCanCreateFolder(false);
Expand All @@ -176,7 +183,7 @@ export const DirectoryPickerModal: React.FC<DirectoryPickerModalProps> = ({
} finally {
setIsLoading(false);
}
}, [pathInput, api, loadDirectory]);
}, [pathInput, liveApi, loadDirectory]);

const handleOpenChange = useCallback(
(open: boolean) => {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import React, { useEffect, useRef, useState } from "react";
import { FileInput, Loader2 } from "lucide-react";
import { ChatDockSurface } from "@/browser/components/ChatPane/chatDockColumn";
import { useAPI } from "@/browser/contexts/API";
import { useAPI, useLiveAPI } from "@/browser/contexts/API";
import { useOptionalCommandRegistry } from "@/browser/contexts/CommandRegistryContext";
import { useOptionalWorkspaceMetadata } from "@/browser/contexts/WorkspaceContext";
import { refetchPostCompactionState } from "@/browser/hooks/usePostCompactionState";
Expand All @@ -26,7 +26,7 @@ export interface LegacyPlanImportNoticeProps {
* The backend copies it (never replacing an existing plan) and leaves the legacy file in place.
*/
export const LegacyPlanImportNotice: React.FC<LegacyPlanImportNoticeProps> = (props) => {
const { api } = useAPI();
const liveApi = useLiveAPI();
const registerSource = useOptionalCommandRegistry()?.registerSource;
const [importing, setImporting] = useState(false);
const [error, setError] = useState<string | null>(null);
Expand All @@ -39,15 +39,15 @@ export const LegacyPlanImportNotice: React.FC<LegacyPlanImportNoticeProps> = (pr

const importPlan = async () => {
if (inFlightRef.current) return;
if (!api) {
if (!liveApi) {
setError("Not connected to the backend");
return;
}
inFlightRef.current = true;
setImporting(true);
setError(null);
try {
const result = await api.workspace.importLegacyPlan({ workspaceId: props.workspaceId });
const result = await liveApi.workspace.importLegacyPlan({ workspaceId: props.workspaceId });
if (!result.success) {
setError(result.error);
} else if (result.data.status === "nothing_to_import") {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { cleanup, fireEvent, render, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test";
import { installDom } from "../../../../tests/ui/dom";
import { createConnectionHarness, GAP_MODES } from "@/browser/contexts/connection.testHarness";
let cleanupDom: (() => void) | null = null;
let currentClientMock: TestApiOverrides<APIClient> = {};

Expand Down Expand Up @@ -126,4 +127,43 @@ describe("ProjectAddForm", () => {
await waitFor(() => expect(receivedSignal?.aborted).toBe(true));
await waitFor(() => expect(onIsCreatingChange).toHaveBeenCalledWith(false));
});

test.each([...GAP_MODES])(
"adding the existing clone while offline says it is not connected (%s)",
async (mode) => {
const createProject = mock(() => Promise.resolve({ success: false as const, error: "x" }));
const harness = createConnectionHarness(mode, () => ({
projects: {
getDefaultProjectDir: () => Promise.resolve("/tmp"),
create: createProject,
clone: () =>
Promise.resolve(
(async function* () {
await Promise.resolve();
yield {
type: "error" as const,
code: "destination_exists" as const,
error: "Destination already exists",
normalizedPath: "/tmp/repo",
};
})()
),
},
}));
const view = render(<ProjectAddForm isOpen onSuccess={() => undefined} />, {
wrapper: harness.Wrapper,
});
fireEvent.click(view.getByText("Clone repo"));
const repoInput = view.getByPlaceholderText("owner/repo or https://github.com/...");
const user = userEvent.setup({ document: repoInput.ownerDocument });
await user.type(repoInput, "owner/repo");
fireEvent.click(view.getByText("Clone Project"));
const addButton = await view.findByText("Add this project instead");

await harness.drop();
fireEvent.click(addButton);
await view.findByText("Not connected to server");
expect(createProject).not.toHaveBeenCalled();
}
);
});
39 changes: 28 additions & 11 deletions src/browser/components/ProjectCreateModal/ProjectCreateModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ import {
ToggleGroupItem,
} from "@/browser/components/ToggleGroupPrimitive/ToggleGroupPrimitive";
import type { ProjectConfig } from "@/node/config";
import { useAPI } from "@/browser/contexts/API";
import { useAPI, useLiveAPI } from "@/browser/contexts/API";

type ApiClient = ReturnType<typeof useAPI>["api"];

Expand Down Expand Up @@ -125,6 +125,7 @@ export const ProjectCreateForm = React.forwardRef<ProjectCreateFormHandle, Proje
ref
) {
const { api } = useAPI();
const liveApi = useLiveAPI();
const resolvedSubmitLabel =
submitLabel ?? (createNewGitRepo ? "Create Project" : "Add Project");
const resolvedPlaceholder =
Expand Down Expand Up @@ -190,19 +191,19 @@ export const ProjectCreateForm = React.forwardRef<ProjectCreateFormHandle, Proje
}

setError("");
if (!api) {
if (!liveApi) {
setError("Not connected to server");
return false;
}
setCreating(true);

try {
// First check if project already exists
const existingProjects = await api.projects.list();
const existingProjects = await liveApi.projects.list();
const existingPaths = new Map(existingProjects);

// Backend handles path resolution (bare names → ~/.xum/projects/name)
const result = await api.projects.create({
const result = await liveApi.projects.create({
projectPath: trimmedPath,
initGit: createNewGitRepo || undefined,
});
Expand Down Expand Up @@ -233,7 +234,17 @@ export const ProjectCreateForm = React.forwardRef<ProjectCreateFormHandle, Proje
} finally {
setCreating(false);
}
}, [api, createNewGitRepo, isCreating, onClose, onSuccess, path, reset, setCreating, setError]);
}, [
liveApi,
createNewGitRepo,
isCreating,
onClose,
onSuccess,
path,
reset,
setCreating,
setError,
]);

const handleKeyDown = useCallback(
(e: React.KeyboardEvent) => {
Expand Down Expand Up @@ -401,6 +412,7 @@ function processTerminalOutput(raw: string): string {
const ProjectCloneForm = React.forwardRef<ProjectCloneFormHandle, ProjectCloneFormProps>(
function ProjectCloneForm(props, ref) {
const { api } = useAPI();
const liveApi = useLiveAPI();
const [repoUrl, setRepoUrl] = useState("");
const [cloneParentDir, setCloneParentDir] = useState(props.defaultProjectDir);
const [hasEditedCloneParentDir, setHasEditedCloneParentDir] = useState(false);
Expand Down Expand Up @@ -520,7 +532,7 @@ const ProjectCloneForm = React.forwardRef<ProjectCloneFormHandle, ProjectCloneFo
return false;
}

if (!api) {
if (!liveApi) {
setError("Not connected to server");
return false;
}
Expand All @@ -535,7 +547,7 @@ const ProjectCloneForm = React.forwardRef<ProjectCloneFormHandle, ProjectCloneFo
abortControllerRef.current = controller;

try {
const cloneEvents = await api.projects.clone(
const cloneEvents = await liveApi.projects.clone(
{
repoUrl: trimmedRepoUrl,
cloneParentDir: trimmedCloneParentDir || undefined,
Expand Down Expand Up @@ -590,10 +602,15 @@ const ProjectCloneForm = React.forwardRef<ProjectCloneFormHandle, ProjectCloneFo
setCreating(false);
}
}
}, [api, isCreating, props, repoUrl, reset, setCreating, setError, trimmedCloneParentDir]);
}, [liveApi, isCreating, props, repoUrl, reset, setCreating, setError, trimmedCloneParentDir]);

const handleAddExistingProject = useCallback(async () => {
if (!api || !destinationExistsPath) {
if (!destinationExistsPath) {
return;
}
// The button stays enabled while offline: say why the click does nothing.
if (!liveApi) {
setError("Not connected to server");
return;
}

Expand All @@ -602,7 +619,7 @@ const ProjectCloneForm = React.forwardRef<ProjectCloneFormHandle, ProjectCloneFo
addProjectAbortControllerRef.current = controller;

try {
const result = await api.projects.create(
const result = await liveApi.projects.create(
{ projectPath: destinationExistsPath },
{ signal: controller.signal }
);
Expand Down Expand Up @@ -631,7 +648,7 @@ const ProjectCloneForm = React.forwardRef<ProjectCloneFormHandle, ProjectCloneFo
setIsAddingProject(false);
}
}
}, [api, destinationExistsPath, props, reset, setError]);
}, [liveApi, destinationExistsPath, props, reset, setError]);

const handleRetry = useCallback(() => {
setError("");
Expand Down
Loading
Loading