diff --git a/docs/screenshots/build-dialog.png b/docs/screenshots/build-dialog.png
new file mode 100644
index 0000000..1e37a5f
Binary files /dev/null and b/docs/screenshots/build-dialog.png differ
diff --git a/docs/screenshots/build-output-panel.png b/docs/screenshots/build-output-panel.png
new file mode 100644
index 0000000..f2d5fd5
Binary files /dev/null and b/docs/screenshots/build-output-panel.png differ
diff --git a/docs/screenshots/template-editor.png b/docs/screenshots/template-editor.png
new file mode 100644
index 0000000..556fe75
Binary files /dev/null and b/docs/screenshots/template-editor.png differ
diff --git a/docs/screenshots/workspace-dialog.png b/docs/screenshots/workspace-dialog.png
new file mode 100644
index 0000000..5662cd1
Binary files /dev/null and b/docs/screenshots/workspace-dialog.png differ
diff --git a/internal/docker/docker.go b/internal/docker/docker.go
index 7034835..c62f363 100644
--- a/internal/docker/docker.go
+++ b/internal/docker/docker.go
@@ -6,6 +6,7 @@ import (
"github.com/docker/docker/api/types"
"github.com/docker/go-connections/nat"
"strconv"
+ "strings"
)
// ContainerSSHHostPort returns the port on the host that is exposing the internal ssh port of the given container info
@@ -33,3 +34,8 @@ func ContainerHostPort(ctx context.Context, container types.ContainerJSON, port
}
return port
}
+
+// CleanErrorMessage removes unnecessary parts in a docker sdk error message, such as "Error response from daemon:"
+func CleanErrorMessage(msg string) string {
+ return strings.Replace(msg, "Error response from daemon: ", "", 1)
+}
diff --git a/internal/template/template_manager.go b/internal/template/template_manager.go
index b7181ee..5bce8e6 100644
--- a/internal/template/template_manager.go
+++ b/internal/template/template_manager.go
@@ -14,7 +14,7 @@ import (
"github.com/docker/docker/errdefs"
"github.com/google/uuid"
"github.com/uptrace/bun"
- "strings"
+ "tesseract/internal/docker"
"time"
)
@@ -259,7 +259,7 @@ func (mgr *templateManager) buildTemplate(ctx context.Context, template *templat
// the docker sdk returns an error message that looks like:
// "Error response from daemon: dockerfile parse error on line 1: unknown instruction: FR (did you mean FROM?)"
// we don't want the "error response..." part because it is meaningless
- message: strings.Replace(err.Error(), "Error response from daemon: ", "", 1),
+ message: docker.CleanErrorMessage(err.Error()),
}
}
return nil, err
diff --git a/internal/workspace/errors.go b/internal/workspace/errors.go
new file mode 100644
index 0000000..506cf81
--- /dev/null
+++ b/internal/workspace/errors.go
@@ -0,0 +1,9 @@
+package workspace
+
+type errWorkspaceExists struct {
+ message string
+}
+
+func (err *errWorkspaceExists) Error() string {
+ return err.message
+}
diff --git a/internal/workspace/http_handlers.go b/internal/workspace/http_handlers.go
index 887ef43..c86e3f0 100644
--- a/internal/workspace/http_handlers.go
+++ b/internal/workspace/http_handlers.go
@@ -6,6 +6,7 @@ import (
"fmt"
"github.com/labstack/echo/v4"
"net/http"
+ "tesseract/internal/apierror"
)
type createWorkspaceRequestBody struct {
@@ -86,6 +87,12 @@ func createWorkspace(c echo.Context, workspaceName string) error {
if errors.Is(err, errImageNotFound) {
return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("no image with id %v exists", body.ImageID))
}
+
+ var errWorkspaceExists *errWorkspaceExists
+ if errors.As(err, &errWorkspaceExists) {
+ return apierror.New(http.StatusBadRequest, "WORKSPACE_EXISTS", errWorkspaceExists.message)
+ }
+
return err
}
diff --git a/internal/workspace/workspace_manager.go b/internal/workspace/workspace_manager.go
index f8045dd..1850542 100644
--- a/internal/workspace/workspace_manager.go
+++ b/internal/workspace/workspace_manager.go
@@ -7,6 +7,7 @@ import (
"fmt"
"github.com/docker/docker/api/types/container"
"github.com/docker/docker/client"
+ "github.com/docker/docker/errdefs"
"github.com/docker/go-connections/nat"
"github.com/google/uuid"
"github.com/uptrace/bun"
@@ -174,6 +175,11 @@ func (mgr workspaceManager) createWorkspace(ctx context.Context, opts createWork
res, err := mgr.dockerClient.ContainerCreate(ctx, containerConfig, hostConfig, nil, nil, opts.name)
if err != nil {
+ if errdefs.IsConflict(err) {
+ return nil, &errWorkspaceExists{
+ message: docker.CleanErrorMessage(err.Error()),
+ }
+ }
return nil, err
}
diff --git a/web/src/api.ts b/web/src/api.ts
index 1ff2fe0..12ea5b9 100644
--- a/web/src/api.ts
+++ b/web/src/api.ts
@@ -6,6 +6,7 @@ interface ApiErrorResponse {
}
const API_ERROR_BAD_TEMPLATE = "BAD_TEMPLATE";
+const API_ERROR_WORKSPACE_EXISTS = "WORKSPACE_EXISTS";
enum ApiError {
NotFound = "NOT_FOUND",
@@ -45,5 +46,11 @@ function isApiErrorResponse(error: unknown): error is ApiErrorResponse {
);
}
-export { API_ERROR_BAD_TEMPLATE, ApiError, fetchApi, isApiErrorResponse };
+export {
+ API_ERROR_BAD_TEMPLATE,
+ API_ERROR_WORKSPACE_EXISTS,
+ ApiError,
+ fetchApi,
+ isApiErrorResponse,
+};
export type { ApiErrorResponse };
diff --git a/web/src/templates/template-editor.tsx b/web/src/templates/template-editor.tsx
index 4dd3ba0..09d1c95 100644
--- a/web/src/templates/template-editor.tsx
+++ b/web/src/templates/template-editor.tsx
@@ -15,7 +15,7 @@ import {
import { cn } from "@/lib/utils";
import { Link, useRouter } from "@tanstack/react-router";
import { ArrowLeft, ChevronDown, ChevronUp, Loader2 } from "lucide-react";
-import { useEffect, useId, useRef } from "react";
+import { useEffect, useRef } from "react";
import { useStore } from "zustand";
import { useTemplate, useTemplateFile, useUpdateTemplateFile } from "./api";
import { templateEditorRoute } from "./routes";
diff --git a/web/src/workspaces/new-workspace-dialog.tsx b/web/src/workspaces/new-workspace-dialog.tsx
index dc5daa9..f0f9f97 100644
--- a/web/src/workspaces/new-workspace-dialog.tsx
+++ b/web/src/workspaces/new-workspace-dialog.tsx
@@ -30,6 +30,7 @@ import { nonempty, object, pattern, string, type Infer } from "superstruct";
import { useCreateWorkspace, useWorkspaceRuntimes } from "./api";
import type { TemplateImage } from "@/templates/types";
import type { WorkspaceRuntime } from "./types";
+import { API_ERROR_WORKSPACE_EXISTS, isApiErrorResponse } from "@/api";
interface NewWorkspaceDialogProps {
onCreateSuccess: () => void;
@@ -131,28 +132,47 @@ function NewWorkspaceForm({
useEffect(() => {
switch (status.type) {
case "error":
- toast({
- variant: "destructive",
- title: "Failed to create the workspace.",
- action: (
- {
- formRef.current?.requestSubmit();
- }}
- altText="Try again"
- >
- Try again
-
- ),
- });
+ if (isApiErrorResponse(status.error)) {
+ let toastTitle = "";
+ switch (status.error.code) {
+ case API_ERROR_WORKSPACE_EXISTS:
+ toastTitle = "Workspace already exists.";
+ break;
+ default:
+ toastTitle = "Failed to create the workspace.";
+ break;
+ }
+ toast({
+ variant: "destructive",
+ title: toastTitle,
+ description: status.error.error,
+ });
+ } else {
+ toast({
+ variant: "destructive",
+ title: "Failed to create the workspace.",
+ description: "Unknown error",
+ action: (
+ {
+ formRef.current?.requestSubmit();
+ }}
+ altText="Try again"
+ >
+ Try again
+
+ ),
+ });
+ }
break;
+
case "ok":
onCreateSuccess();
break;
default:
break;
}
- }, [status.type, toast, onCreateSuccess]);
+ }, [status, toast, onCreateSuccess]);
async function onSubmit(values: Infer) {
await createWorkspace({