Skip to content
Closed
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
2 changes: 2 additions & 0 deletions packages/app/src/app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ function BodyTypography() {
export function AppBaseProviders(
props: ParentProps<{
locale?: Locale
onError?: (error: unknown) => void
onNativeTranslations?: Parameters<typeof LanguageProvider>[0]["onNativeTranslations"]
onThemeApplied?: (mode: "light" | "dark", scheme: "system" | "light" | "dark") => void
}>,
Expand All @@ -64,6 +65,7 @@ export function AppBaseProviders(
<UiI18nBridge>
<ErrorBoundary
fallback={(error) => {
props.onError?.(error)
void import("@sentry/solid").then(({ captureException }) => captureException(error))

return <ErrorPage error={error} />
Expand Down
3 changes: 2 additions & 1 deletion packages/app/src/desktop.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,10 @@ export { AppBaseProviders, AppInterface, preloadRoute } from "./app"
export { useCommand } from "./shell/commands/command"
export { currentRoute, type LayoutRoute, useCurrentRoute } from "./shell/state/layout"
export { loadLocaleDict, normalizeLocale, type Locale, useLanguage } from "./runtime/i18n/language"
export { type FatalRendererErrorLog, type Platform, PlatformProvider } from "./runtime/platform/platform"
export { type FatalRendererErrorLog, type Platform, PlatformProvider, usePlatform } from "./runtime/platform/platform"
export { ServerConnection, useServers } from "./runtime/server/registry"
export { useGlobal } from "./runtime/server/runtime"
export { formatProjectLocationError, formatServerError, projectLocationError } from "./runtime/server/errors"
export { useTabs } from "./shell/tabs/tabs"
export { createDraftStore } from "./runtime/persistence/drafts"
export { createNamespaceStorage, type NamespaceStorage } from "./runtime/persistence/namespace"
Expand Down
4 changes: 2 additions & 2 deletions packages/app/src/home/model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -118,8 +118,8 @@ export function createHomeController() {
return
setSelection(toggleHomeProjectSelection(selection(), key, directory))
},
add: (conn: ServerConnection.Any, directories: string[]) => {
const directory = addProjects(global.ensureServerCtx(conn), directories)
add: (conn: ServerConnection.Any, directories: string[], onError?: (directory: string, error: unknown) => void) => {
const directory = addProjects(global.ensureServerCtx(conn), directories, onError)
if (!directory) return
setSelection({ server: ServerConnection.key(conn), directory })
},
Expand Down
8 changes: 6 additions & 2 deletions packages/app/src/home/projects/add.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
import type { ServerCtx } from "@/runtime/server/runtime"

export function addProjects(context: ServerCtx, directories: string[]) {
export function addProjects(
context: ServerCtx,
directories: string[],
onError?: (directory: string, error: unknown) => void,
) {
const directory = directories[0]
if (!directory) return

Expand All @@ -11,7 +15,7 @@ export function addProjects(context: ServerCtx, directories: string[]) {
.list({ path: ".", location })
.then(() => context.sdk.api.location.get({ location }))
.then((value) => context.sync.child(item, { bootstrap: false })[1]("project", value.project.id))
.catch(() => undefined)
.catch((error: unknown) => onError?.(item, error))
context.projects.open(item)
})
context.projects.touch(directory)
Expand Down
51 changes: 40 additions & 11 deletions packages/app/src/home/projects/controller.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import { Persistence } from "@/runtime/persistence/schema"
import type { HomeController } from "../model"
import { useGlobal } from "@/runtime/server/runtime"
import { SessionTransfer } from "@opencode/schema/session-transfer"
import { formatProjectLocationError, projectLocationError } from "@/runtime/server/errors"
import { useRevealProject } from "./reveal"

export const HomeServersSchema = Schema.Struct({
Expand Down Expand Up @@ -43,12 +44,48 @@ export function createHomeProjectsController(home: HomeController) {
return [project.worktree, ...(project.sandboxes ?? [])]
}

function closeProject(conn: ServerConnection.Any, directory: string) {
const next = closeHomeProject(
home.selection.value(),
ServerConnection.key(conn),
home.server.context(conn).projects,
directory,
)
if (next) home.selection.set(next)
}

// Only a typed folder error is actionable here; other failures keep the existing quiet behavior.
function showUnavailable(conn: ServerConnection.Any, directory: string, error: unknown) {
const location = projectLocationError(error)
if (!location) return
showToast({
variant: "error",
persistent: true,
title: language.t(
location.type === "missing" ? "home.project.missing.title" : "toast.project.permissionDenied.title",
),
description: formatProjectLocationError(
location,
language.t,
ServerConnection.local(conn) && platform.platform === "desktop" && platform.os === "macos",
),
actions:
location.type === "missing"
? [{ label: language.t("toast.project.missing.remove"), onClick: () => closeProject(conn, directory) }]
: undefined,
})
}

function add(conn: ServerConnection.Any, directories: string[]) {
home.project.add(conn, directories, (directory, error) => showUnavailable(conn, directory, error))
}

function choose(conn: ServerConnection.Any) {
pickDirectory({
server: conn,
title: language.t("command.project.open"),
multiple: true,
onSelect: (result) => home.project.add(conn, homeProjectDirectories(result)),
onSelect: (result) => add(conn, homeProjectDirectories(result)),
})
}

Expand Down Expand Up @@ -95,7 +132,7 @@ export function createHomeProjectsController(home: HomeController) {
if (authenticate(conn, () => home.project.select(conn, directory))) return
home.project.select(conn, directory)
},
add: home.project.add,
add,
openNewSession: (conn: ServerConnection.Any, directory: string) => {
if (authenticate(conn, () => home.project.openProjectNewSession(conn, directory))) return
home.project.openProjectNewSession(conn, directory)
Expand Down Expand Up @@ -150,15 +187,7 @@ export function createHomeProjectsController(home: HomeController) {
if (home.server.health(conn)?.healthy === false) return
choose(conn)
},
close: (conn: ServerConnection.Any, directory: string) => {
const next = closeHomeProject(
home.selection.value(),
ServerConnection.key(conn),
home.server.context(conn).projects,
directory,
)
if (next) home.selection.set(next)
},
close: closeProject,
move: (conn: ServerConnection.Any, worktree: string, index: number) => {
home.server.context(conn).projects.move(worktree, index)
},
Expand Down
46 changes: 40 additions & 6 deletions packages/app/src/new-session/route.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { createMemo, Show, type ParentProps } from "solid-js"
import { CommentsProvider } from "@/composer/comments"
import { FileProvider } from "@/workspaces/files/model"
import { useGlobal } from "@/runtime/server/runtime"
import { LocationProvider } from "@/workspaces/location"
import { LocationProvider, useWorkspaceLocation } from "@/workspaces/location"
import { ModelsProvider } from "@/providers/models/models"
import { ComposerPersistenceProvider } from "@/composer/persistence"
import { ServerProvider, useServer } from "@/runtime/server/current"
Expand All @@ -13,6 +13,8 @@ import { SessionUIProvider } from "@/shell/routes/session-ui-provider"
import NewSession from "@/new-session/screen"
import { IncompatibleServerPanel } from "@/session/incompatible-server-panel"
import { SessionPanelFrame, SessionRouteFrame } from "@/session/session-frame"
import { LocationUnavailable } from "@/session/location-unavailable"
import { useLanguage } from "@/runtime/i18n/language"

export function DraftRoute() {
const [search] = useSearchParams<{ draftId?: string }>()
Expand Down Expand Up @@ -67,17 +69,49 @@ function ResolvedDraftContent(props: { draft: DraftTab }) {
>
<ModelsProvider directory={props.draft.directory}>
<LocationProvider directory={props.draft.directory}>
<SessionUIProvider directory={props.draft.directory} server={props.draft.server}>
<DraftProviders>
<NewSession draftId={props.draft.draftID} />
</DraftProviders>
</SessionUIProvider>
<DraftPage draft={props.draft} />
</LocationProvider>
</ModelsProvider>
</Show>
)
}

function DraftPage(props: { draft: DraftTab }) {
const server = useServer()
const tabs = useTabs()
const language = useLanguage()
const location = useWorkspaceLocation()
return (
<Show
when={location().error}
fallback={
<SessionUIProvider directory={props.draft.directory} server={props.draft.server}>
<DraftProviders>
<NewSession draftId={props.draft.draftID} />
</DraftProviders>
</SessionUIProvider>
}
>
<SessionRouteFrame padded>
<SessionPanelFrame raised>
<LocationUnavailable
moveLabel={language.t("session.location.choose")}
onMove={async (directory) => {
server.ctx.projects.open(directory)
server.ctx.projects.touch(directory)
tabs.updateDraft(props.draft.draftID, { directory, worktree: undefined, branch: undefined })
}}
onCloseTab={() => {
const index = tabs.store.findIndex((tab) => tab.type === "draft" && tab.draftID === props.draft.draftID)
if (index !== -1) tabs.closeTab(index)
}}
/>
</SessionPanelFrame>
</SessionRouteFrame>
</Show>
)
}

// The draft page only renders the prompt composer.
// FileProvider and CommentsProvider stay because Composer uses file search and comment context.
function DraftProviders(props: ParentProps) {
Expand Down
15 changes: 15 additions & 0 deletions packages/app/src/runtime/i18n/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -393,6 +393,21 @@ export const dict = {
"toast.project.copyID.failed.description": "An error occurred while copying the project ID",

"toast.project.reloadFailed.title": "Failed to reload {{project}}",
"toast.project.defaultUnavailable.title": "Cannot open default project",
"toast.project.missing.remove": "Remove project",
"session.location.move": "Move session",
"session.location.choose": "Choose directory",
"session.location.retry": "Try again",
"session.location.closeProject": "Close project",
"toast.project.permissionDenied.title": "OpenCode can't access this folder",
"error.project.missing":
"{{directory}} was moved, renamed, or deleted. Restore the folder at that path, or remove this project from OpenCode and open it from its new location.",
"error.project.permissionDenied":
"OpenCode doesn't have permission to open {{directory}}. Make sure your user account can read the folder, then try again.",
"error.project.permissionDenied.macos":
"macOS is blocking OpenCode from opening {{directory}}. In System Settings > Privacy & Security > Files & Folders (or Full Disk Access), allow OpenCode. If you started OpenCode from a terminal, allow that terminal app instead. If it still fails, run `opencode service restart` or restart your Mac.",
"error.project.unavailable":
"Couldn't open the project at {{directory}}. Check that the folder exists and OpenCode has access to it.",
"toast.migration.failed.title": "Data migration failed",
"toast.migration.progress.clearingOldEvents": "Clearing old events",
"toast.migration.progress.migratingSessions": "Migrating sessions",
Expand Down
41 changes: 39 additions & 2 deletions packages/app/src/runtime/server/errors.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,18 @@
import { describe, expect, test } from "bun:test"
import type { FileNotFoundError, SessionNotFoundError } from "@opencode/client/promise"
import type {
FileNotFoundError,
LocationNotFoundError,
LocationPermissionDeniedError,
SessionNotFoundError,
} from "@opencode/client/promise"
import type { ConfigInvalidError, ProviderModelNotFoundError } from "./errors"
import { formatServerError, isSessionNotFoundError, parseReadableConfigInvalidError } from "./errors"
import {
formatProjectLocationError,
formatServerError,
isSessionNotFoundError,
parseReadableConfigInvalidError,
projectLocationError,
} from "./errors"

function fill(text: string, vars?: Record<string, string | number>) {
if (!vars) return text
Expand Down Expand Up @@ -54,6 +65,32 @@ describe("parseReadableConfigInvalidError", () => {
})

describe("formatServerError", () => {
test("explains missing and denied project folders without misclassifying other failures", () => {
const missing = {
_tag: "LocationNotFoundError",
location: { directory: "C:\\Users\\Test User\\Projects\\moved-project" },
message: "Location not found",
} satisfies LocationNotFoundError
const denied = {
_tag: "LocationPermissionDeniedError",
location: { directory: "/Users/example/Documents/private-project" },
message: "Location access denied",
} satisfies LocationPermissionDeniedError
for (const error of [missing, new Error("Request failed", { cause: { body: missing, status: 404 } })]) {
expect(projectLocationError(error)).toEqual({ type: "missing", directory: missing.location.directory })
expect(formatServerError(error)).toContain(`${missing.location.directory} was moved`)
}
const info = projectLocationError(new Error("Request failed", { cause: { body: denied, status: 403 } }))
expect(info).toEqual({ type: "denied", directory: denied.location.directory })
expect(formatServerError(denied)).toContain(`permission to open ${denied.location.directory}`)
expect(formatServerError(denied)).not.toContain("Privacy & Security")
if (info) expect(formatProjectLocationError(info, undefined, true)).toContain("Privacy & Security")
expect(
projectLocationError({ _tag: "FileNotFoundError", path: "file.txt", message: "File not found" }),
).toBeUndefined()
expect(projectLocationError(new Error("HTTP 500"))).toBeUndefined()
})

test.each([
{
name: "trimmed config message without issues",
Expand Down
27 changes: 27 additions & 0 deletions packages/app/src/runtime/server/errors.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
import { isLocationNotFoundError, isLocationPermissionDeniedError } from "@opencode/client/promise"
import { dict } from "../i18n/en"

export type ConfigInvalidError = {
name: "ConfigInvalidError"
data: {
Expand Down Expand Up @@ -25,7 +28,31 @@ function tr(translator: Translator | undefined, key: string, text: string, vars?
return out
}

export function projectLocationError(error: unknown) {
const unwrapped = unwrapNamedError(error)
if (isLocationNotFoundError(unwrapped)) return { type: "missing" as const, directory: unwrapped.location.directory }
if (isLocationPermissionDeniedError(unwrapped))
return { type: "denied" as const, directory: unwrapped.location.directory }
}

// Pass `macos` only when the server runs on this Mac; its privacy settings then explain the denial.
export function formatProjectLocationError(
info: NonNullable<ReturnType<typeof projectLocationError>>,
translate?: Translator,
macos?: boolean,
) {
const key =
info.type === "missing"
? "error.project.missing"
: macos
? "error.project.permissionDenied.macos"
: "error.project.permissionDenied"
return tr(translate, key, dict[key].replace("{{directory}}", info.directory), { directory: info.directory })
}

export function formatServerError(error: unknown, translate?: Translator, fallback?: string) {
const location = projectLocationError(error)
if (location) return formatProjectLocationError(location, translate)
const unwrapped = unwrapNamedError(error)
if (isConfigInvalidErrorLike(unwrapped)) return parseReadableConfigInvalidError(unwrapped, translate)
if (isProviderModelNotFoundErrorLike(unwrapped)) return parseReadableProviderModelNotFoundError(unwrapped, translate)
Expand Down
Loading
Loading