Skip to content
Open
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
66 changes: 57 additions & 9 deletions packages/app/src/home/projects/controller.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ import type { HomeController } from "../model"
import { useGlobal } from "@/runtime/server/runtime"
import { SessionTransfer } from "@opencode/schema/session-transfer"
import { useSshAuthenticate } from "@/servers/ssh/authenticate"
import { formatServerError, isLocationPermissionDeniedError } from "@/runtime/server/errors"
import { getFilename } from "@opencode/util/path"

export const HomeServersSchema = Schema.Struct({
collapsed: Persistence.record(Persistence.fallback(Schema.Boolean, () => false)),
Expand Down Expand Up @@ -46,12 +48,44 @@ export function createHomeProjectsController(home: HomeController) {
return platform.platform === "desktop" && !!platform.openPath && ServerConnection.local(conn)
}

function accessible(conn: ServerConnection.Any, directory: string) {
if (!ServerConnection.local(conn) || platform.platform !== "desktop") return Promise.resolve(true)
return home.server
.context(conn)
.sdk.api.location.get({ location: { directory } })
.then(
() => true,
(error: unknown) => {
showToast({
variant: "error",
title: language.t(
isLocationPermissionDeniedError(error)
? "toast.project.accessDenied.title"
: "toast.project.reloadFailed.title",
{ project: getFilename(directory) },
),
description: formatServerError(error, language.t),
})
return false
},
)
}

function add(conn: ServerConnection.Any, directories: string[]) {
void Promise.all(directories.map(async (directory) => ({ directory, ok: await accessible(conn, directory) }))).then(
(items) => {
const available = items.filter((item) => item.ok).map((item) => item.directory)
if (available.length) home.project.add(conn, available)
},
)
}

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,13 +129,24 @@ export function createHomeProjectsController(home: HomeController) {
recentlyClosed: home.project.recentlyClosed,
homedir: home.project.homedir,
select: (conn: ServerConnection.Any, directory: string) => {
if (authenticate(conn, () => home.project.select(conn, directory))) return
home.project.select(conn, directory)
const select = () => {
if (home.selection.value().directory === directory) return home.project.select(conn, directory)
void accessible(conn, directory).then((ok) => {
if (ok) home.project.select(conn, directory)
})
}
if (authenticate(conn, select)) return
select()
},
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)
const open = () => {
void accessible(conn, directory).then((ok) => {
if (ok) home.project.openProjectNewSession(conn, directory)
})
}
if (authenticate(conn, open)) return
open()
},
canImportSession: !!platform.openAttachmentPickerDialog,
importSession: (conn: ServerConnection.Any, project: LocalProject) => {
Expand Down Expand Up @@ -133,9 +178,12 @@ export function createHomeProjectsController(home: HomeController) {
})
},
edit: (conn: ServerConnection.Any, project: LocalProject) => {
settings.openProject({
server: ServerConnection.key(conn),
project: project.worktree,
void accessible(conn, project.worktree).then((ok) => {
if (!ok) return
settings.openProject({
server: ServerConnection.key(conn),
project: project.worktree,
})
})
},
unseenCount: (conn: ServerConnection.Any, project: LocalProject) => {
Expand Down
7 changes: 7 additions & 0 deletions packages/app/src/runtime/i18n/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -688,6 +688,11 @@ export const dict = {

"toast.session.listFailed.title": "Failed to load sessions for {{project}}",
"toast.project.reloadFailed.title": "Failed to reload {{project}}",
"error.project.directoryMissing":
"The project folder at {{directory}} no longer exists. Restore it at that path, or close this project and open it from its new location.",
"toast.project.accessDenied.title": "Cannot open {{project}}",
"error.project.permissionDenied":
"OpenCode can't access {{directory}}. Allow access to this folder in your system's privacy settings, then try again or choose another project.",
"toast.migration.failed.title": "Data migration failed",

"toast.update.title": "Update available",
Expand All @@ -697,11 +702,13 @@ export const dict = {

"error.page.title": "Something went wrong",
"error.page.title.status": "Server request failed",
"error.page.title.projectUnavailable": "Cannot open project folder",
"error.page.description": "An error occurred while loading the application.",
"error.page.description.status": "The server returned an HTTP {{status}} response.",
"error.page.description.localServerStartup": "An error occurred while starting the local server.",
"error.page.details.label": "Error Details",
"error.page.action.restart": "Restart",
"error.page.action.goHome": "Go to projects",
"error.page.action.reload": "Reload",
"error.page.action.report": "Report Error",
"error.page.action.reported": "Error Reported",
Expand Down
3 changes: 3 additions & 0 deletions packages/app/src/runtime/platform/platform.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,9 @@ type PlatformBase = {
/** Restart the app */
restart(): Promise<void>

/** Return to the project list when a route fails before the router can recover. */
recoverToHome?(): void

/** Send a system notification */
notify(title: string, description?: string, onClick?: () => void): Promise<void>

Expand Down
49 changes: 48 additions & 1 deletion packages/app/src/runtime/server/errors.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,13 @@
import { describe, expect, test } from "bun:test"
import type { FileNotFoundError, SessionNotFoundError } from "@opencode/client/promise"
import type { ConfigInvalidError, ProviderModelNotFoundError } from "./errors"
import { formatServerError, isSessionNotFoundError, parseReadableConfigInvalidError } from "./errors"
import {
formatServerError,
isLocationPermissionDeniedError,
isLocationProjectError,
isSessionNotFoundError,
parseReadableConfigInvalidError,
} from "./errors"

function fill(text: string, vars?: Record<string, string | number>) {
if (!vars) return text
Expand All @@ -20,6 +26,7 @@ function useLanguageMock() {
"error.chain.modelNotFound": "Modelo nao encontrado: {{provider}}/{{model}}",
"error.chain.didYouMean": "Voce quis dizer: {{suggestions}}",
"error.chain.checkConfig": "Revise provider/model no config",
"error.project.directoryMissing": "Pasta ausente: {{directory}}",
}
return {
t(key: string, vars?: Record<string, string | number>) {
Expand Down Expand Up @@ -97,6 +104,46 @@ describe("formatServerError", () => {
expect(formatServerError(error, language.t)).toBe("File not found: deleted.txt")
})

test("explains how to recover a missing project directory", () => {
const directory = "C:\\Users\\Test User\\Projects\\missing-project"
const error = new Error("Request failed with status 400", {
cause: {
body: {
_tag: "LocationDirectoryNotFoundError",
directory,
message: `Project directory not found: ${directory}`,
},
status: 400,
},
})

expect(formatServerError(error)).toBe(
`The project folder at ${directory} no longer exists. Restore it at that path, or close this project and open it from its new location.`,
)
expect(formatServerError(error, language.t)).toBe(`Pasta ausente: ${directory}`)
expect(isLocationProjectError(error)).toBe(true)
})

test("explains a denied project folder and identifies its SDK error", () => {
const directory = "/Users/example/Documents/Projects/private-project"
const error = new Error("Request failed with status 403", {
cause: {
body: {
_tag: "LocationPermissionDeniedError",
directory,
message: `Cannot access project directory: ${directory}`,
},
status: 403,
},
})

expect(isLocationPermissionDeniedError(error)).toBe(true)
expect(isLocationProjectError(error)).toBe(true)
expect(formatServerError(error)).toBe(
`OpenCode can't access ${directory}. Allow access to this folder in your system's privacy settings, then try again or choose another project.`,
)
})

test("returns provided string errors", () => {
expect(formatServerError("Failed to connect to server", language.t)).toBe("Failed to connect to server")
})
Expand Down
44 changes: 44 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 type { LocationDirectoryNotFoundError, LocationPermissionDeniedError } from "@opencode/client/promise"
import { dict } from "../i18n/en"

export type ConfigInvalidError = {
name: "ConfigInvalidError"
data: {
Expand Down Expand Up @@ -27,6 +30,8 @@ function tr(translator: Translator | undefined, key: string, text: string, vars?

export function formatServerError(error: unknown, translate?: Translator, fallback?: string) {
const unwrapped = unwrapNamedError(error)
if (isMissingProjectDirectoryError(unwrapped)) return parseReadableMissingProjectDirectoryError(unwrapped, translate)
if (isLocationPermissionDeniedErrorLike(unwrapped)) return parseReadableLocationPermissionDeniedError(unwrapped, translate)
if (isConfigInvalidErrorLike(unwrapped)) return parseReadableConfigInvalidError(unwrapped, translate)
if (isProviderModelNotFoundErrorLike(unwrapped)) return parseReadableProviderModelNotFoundError(unwrapped, translate)
if (
Expand All @@ -43,6 +48,21 @@ export function formatServerError(error: unknown, translate?: Translator, fallba
return tr(translate, "error.chain.unknown", "Unknown error")
}

export function isLocationPermissionDeniedError(error: unknown) {
return isLocationPermissionDeniedErrorLike(unwrapNamedError(error))
}

export function isLocationProjectError(error: unknown) {
const unwrapped = unwrapNamedError(error)
return isMissingProjectDirectoryError(unwrapped) || isLocationPermissionDeniedErrorLike(unwrapped)
}

function isLocationPermissionDeniedErrorLike(error: unknown): error is LocationPermissionDeniedError {
if (typeof error !== "object" || error === null) return false
if (!("_tag" in error) || error._tag !== "LocationPermissionDeniedError") return false
return "directory" in error && typeof error.directory === "string"
}

function unwrapNamedError(error: unknown): unknown {
if (!(error instanceof Error) || !error.cause || typeof error.cause !== "object") return error
if ("body" in error.cause) return (error.cause as Record<string, unknown>).body
Expand All @@ -69,6 +89,12 @@ export function isSessionNotFoundError(error: unknown, sessionID: string) {
return value._tag === "SessionNotFoundError" && value.sessionID === sessionID
}

function isMissingProjectDirectoryError(error: unknown): error is LocationDirectoryNotFoundError {
if (typeof error !== "object" || error === null) return false
if (!("_tag" in error) || error._tag !== "LocationDirectoryNotFoundError") return false
return "directory" in error && typeof error.directory === "string"
}

function isConfigInvalidErrorLike(error: unknown): error is ConfigInvalidError {
if (typeof error !== "object" || error === null) return false
const o = error as Record<string, unknown>
Expand All @@ -81,6 +107,24 @@ function isProviderModelNotFoundErrorLike(error: unknown): error is ProviderMode
return o.name === "ProviderModelNotFoundError" && typeof o.data === "object" && o.data !== null
}

function parseReadableMissingProjectDirectoryError(errorInput: LocationDirectoryNotFoundError, translator?: Translator) {
return tr(
translator,
"error.project.directoryMissing",
dict["error.project.directoryMissing"].replace("{{directory}}", errorInput.directory),
{ directory: errorInput.directory },
)
}

function parseReadableLocationPermissionDeniedError(errorInput: LocationPermissionDeniedError, translator?: Translator) {
return tr(
translator,
"error.project.permissionDenied",
dict["error.project.permissionDenied"].replace("{{directory}}", errorInput.directory),
{ directory: errorInput.directory },
)
}

export function parseReadableConfigInvalidError(errorInput: ConfigInvalidError, translator?: Translator) {
const file = errorInput.data.path && errorInput.data.path !== "config" ? errorInput.data.path : "config"
const detail = errorInput.data.message?.trim() ?? ""
Expand Down
12 changes: 5 additions & 7 deletions packages/app/src/runtime/server/global-sync/bootstrap.ts
Original file line number Diff line number Diff line change
Expand Up @@ -146,13 +146,11 @@ export async function bootstrapDirectory(input: {
if (loading) input.setStore("status", "partial")

const slow = [
seededProject
? undefined
: () =>
retry(() => input.api.location.get({ location: { directory: input.directory } })).then((location) =>
input.setStore("project", location.project.id),
),
].filter((task): task is () => Promise<void> => !!task)
() =>
retry(() => input.api.location.get({ location: { directory: input.directory } })).then((location) =>
input.setStore("project", location.project.id),
),
]

await waitForPaint()
const slowErrs = errors(await runAll(slow))
Expand Down
22 changes: 18 additions & 4 deletions packages/app/src/shell/errors/error.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { usePlatform } from "@/runtime/platform/platform"
import { useLanguage } from "@/runtime/i18n/language"
import { Icon } from "@opencode/ui/icon"
import { errorDescriptionKey, errorStatus } from "./description"
import { formatServerError, isLocationProjectError } from "@/runtime/server/errors"

export type InitError = {
name: string
Expand Down Expand Up @@ -290,12 +291,20 @@ export const ErrorPage: Component<ErrorPageProps> = (props) => {
<Logo class="w-48 sm:w-58.5 opacity-12 shrink-0" />
<div class="flex flex-col items-center gap-2 text-center">
<h1 class="text-lg font-medium text-text-strong">
{language.t(status() ? "error.page.title.status" : "error.page.title")}
{language.t(
isLocationProjectError(props.error)
? "error.page.title.projectUnavailable"
: status()
? "error.page.title.status"
: "error.page.title",
)}
</h1>
<p class="text-sm text-text-weak">
{status()
? language.t("error.page.description.status", { status: status()! })
: language.t(errorDescriptionKey(props.error))}
{isLocationProjectError(props.error)
? formatServerError(props.error, language.t)
: status()
? language.t("error.page.description.status", { status: status()! })
: language.t(errorDescriptionKey(props.error))}
</p>
</div>
<TextField
Expand All @@ -308,6 +317,11 @@ export const ErrorPage: Component<ErrorPageProps> = (props) => {
hideLabel
/>
<div class="flex flex-row items-center justify-center gap-3 flex-wrap max-w-64">
<Show when={platform.platform === "desktop" && platform.recoverToHome}>
<Button size="large" onClick={() => platform.recoverToHome?.()}>
{language.t("error.page.action.goHome")}
</Button>
</Show>
<Button size="large" onClick={platform.restart}>
{language.t(platform.platform === "web" ? "error.page.action.reload" : "error.page.action.restart")}
</Button>
Expand Down
Loading
Loading