mirror of
https://github.com/anomalyco/opencode.git
synced 2026-11-13 19:46:16 +03:00
feat(app): restore the session location missing prompt (#53268)
Co-authored-by: OpeOginni <107570612+OpeOginni@users.noreply.github.com>
This commit is contained in:
committed by
GitHub
Unverified
parent
473e4a960d
commit
4fb800734d
@@ -66,6 +66,72 @@ for (const endpoint of ["/api/location", "/api/agent"]) {
|
||||
}
|
||||
}
|
||||
|
||||
test("replaces the composer when the location is not found and recovers by moving to another worktree", async ({
|
||||
page,
|
||||
}) => {
|
||||
const directory = "/projects/deleted-worktree"
|
||||
const destination = "/projects/existing-worktree"
|
||||
const sessionID = "ses_location_not_found"
|
||||
const session = { id: sessionID, projectID: fixture.project.id, directory, title: "Missing location" }
|
||||
const moves: unknown[] = []
|
||||
const transport = await installSseTransport(page, { server: fixture.serverKey })
|
||||
await mockOpenCodeServer(page, {
|
||||
directory: fixture.directory,
|
||||
project: fixture.project,
|
||||
provider: fixture.provider,
|
||||
sessions: [session],
|
||||
fileList: () => [],
|
||||
worktrees: [{ directory: fixture.directory }, { directory: destination, strategy: "git" }],
|
||||
pageMessages: () => ({
|
||||
items: [{ id: "msg_saved", type: "user", text: "Keep this session history", time: { created: 1 } }],
|
||||
}),
|
||||
})
|
||||
let requests = 0
|
||||
await page.route("**/api/location?**", (route) => {
|
||||
if (new URL(route.request().url()).searchParams.get("location[directory]") !== directory) return route.fallback()
|
||||
requests++
|
||||
|
||||
return route.fulfill({
|
||||
status: 404,
|
||||
// SAFETY: the server's wire body for a missing Location, which the client decodes into its own error.
|
||||
// oxlint-disable-next-line anti-slop-effect/no-manual-tagged-construction -- see SAFETY above
|
||||
json: { _tag: "LocationNotFoundError", location: { directory }, message: `Location not found: ${directory}` },
|
||||
headers: { "access-control-allow-origin": "*" },
|
||||
})
|
||||
})
|
||||
await page.route(`**/api/session/${sessionID}/move`, (route) => {
|
||||
if (route.request().method() !== "POST") return route.fallback()
|
||||
moves.push(route.request().postDataJSON())
|
||||
|
||||
return route.fulfill({ status: 204, headers: { "access-control-allow-origin": "*" } })
|
||||
})
|
||||
|
||||
await page.goto(`/server/${base64Encode(fixture.serverKey)}/session/${sessionID}`)
|
||||
const status = page.getByRole("status").filter({ hasText: "Session location unavailable" })
|
||||
await expect(status).toContainText(directory)
|
||||
await expect(page.getByText("Keep this session history", { exact: true })).toBeVisible()
|
||||
await expect(page.getByRole("textbox", { name: "Prompt", exact: true })).toHaveCount(0)
|
||||
// A typed not-found answer is final; retrying cannot succeed until the folder changes.
|
||||
expect(requests).toBe(1)
|
||||
|
||||
await transport.waitForConnection()
|
||||
await page.getByRole("button", { name: "Choose worktree", exact: true }).click()
|
||||
await page.getByRole("menuitem", { name: "existing-worktree", exact: true }).click()
|
||||
await expect.poll(() => moves).toEqual([{ directory: destination }])
|
||||
|
||||
session.directory = destination
|
||||
await transport.send({
|
||||
id: "evt_location_not_found_moved",
|
||||
type: "session.moved",
|
||||
created: 2,
|
||||
durable: { aggregateID: sessionID, seq: 1, version: 1 },
|
||||
data: { sessionID, location: { directory: destination }, projectID: fixture.project.id },
|
||||
})
|
||||
await expect(page.getByRole("textbox", { name: "Prompt", exact: true })).toBeEditable()
|
||||
await expect(status).toHaveCount(0)
|
||||
await expect(page.getByText("Keep this session history", { exact: true })).toBeVisible()
|
||||
})
|
||||
|
||||
test("follows a live session move while the agent catalog is still loading", async ({ page }) => {
|
||||
const recovery = recoveryRequests(page)
|
||||
const directory = "/projects/old-tree"
|
||||
|
||||
@@ -2,6 +2,13 @@ import { DESKTOP_NATIVE_ENGLISH } from "./desktop-native"
|
||||
|
||||
export const dict = {
|
||||
...DESKTOP_NATIVE_ENGLISH,
|
||||
"session.location.unavailable": "Session location unavailable",
|
||||
"session.location.description": "Choose another directory to continue this session.",
|
||||
"session.location.choose": "Choose directory",
|
||||
"session.location.worktree": "Choose worktree",
|
||||
"session.location.worktreesFailed": "Failed to load worktrees",
|
||||
"session.location.moving": "Moving session…",
|
||||
"session.location.moveFailed": "Failed to move session",
|
||||
"server.action.authenticate": "Authenticate",
|
||||
"server.status.connecting": "Connecting over SSH…",
|
||||
"server.status.authentication": "Authentication required",
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
import { Button } from "@opencode/ui/button"
|
||||
import { useDialog } from "@opencode/ui/context/dialog"
|
||||
import { DockShell, DockTray } from "@opencode/ui/dock-surface"
|
||||
import { Icon } from "@opencode/ui/icon"
|
||||
import { Menu } from "@opencode/ui/menu"
|
||||
import { getFilename, sameDirectory } from "@opencode/util/path"
|
||||
import { useMutation } from "@tanstack/solid-query"
|
||||
import { createEffect, createResource, For, Show } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { useLanguage } from "@/runtime/i18n/language"
|
||||
import { useData, useServer } from "@/runtime/server/current"
|
||||
import { showToast } from "@/shell/notifications/toast"
|
||||
import { createWorktree } from "@/workspaces/create"
|
||||
import { useDirectoryPicker } from "@/workspaces/selection/picker"
|
||||
|
||||
export function SessionLocationMissing(props: { sessionID: string; projectID: string; directory: string }) {
|
||||
const language = useLanguage()
|
||||
const server = useServer()
|
||||
const data = useData()
|
||||
const dialog = useDialog()
|
||||
const pickDirectory = useDirectoryPicker()
|
||||
const [state, setState] = createStore({ restoreFocus: false, worktreesOpen: false })
|
||||
const project = () => data.project.get(props.projectID)
|
||||
|
||||
const [worktrees] = createResource(
|
||||
() => (state.worktreesOpen ? props.projectID : undefined),
|
||||
async (projectID) => {
|
||||
await server.ctx.sync.worktrees.refresh(projectID)
|
||||
const items = await server.ctx.sync.worktrees.list(projectID)
|
||||
|
||||
if (!items) showToast({ variant: "error", title: language.t("session.location.worktreesFailed") })
|
||||
|
||||
return items ?? []
|
||||
},
|
||||
// Seed latest so even the first fetch does not enter Suspense.
|
||||
{ initialValue: [] },
|
||||
)
|
||||
|
||||
const otherWorktrees = () =>
|
||||
worktrees.latest.filter((item) => item.strategy && !sameDirectory(item.directory, props.directory))
|
||||
|
||||
let button: HTMLButtonElement | undefined
|
||||
|
||||
const move = useMutation(() => ({
|
||||
mutationFn: async (input: { directory?: string }) => {
|
||||
// The missing worktree cannot resolve its own Location, so create from the project's saved checkout.
|
||||
const current = project()
|
||||
|
||||
const destination =
|
||||
input.directory ??
|
||||
(current &&
|
||||
(await createWorktree({
|
||||
api: server.ctx.sdk.api,
|
||||
data,
|
||||
directory: current.canonical,
|
||||
project: { id: current.id, canonical: current.canonical, directory: current.canonical },
|
||||
})))
|
||||
|
||||
if (!destination) return
|
||||
await server.ctx.sdk.api.session.move({ sessionID: props.sessionID, directory: destination })
|
||||
},
|
||||
onError: (error) => {
|
||||
setState("restoreFocus", true)
|
||||
showToast({
|
||||
variant: "error",
|
||||
title: language.t("session.location.moveFailed"),
|
||||
description: error instanceof Error ? error.message : language.t("common.requestFailed"),
|
||||
})
|
||||
},
|
||||
}))
|
||||
|
||||
createEffect(() => {
|
||||
if (!state.restoreFocus || move.isPending || dialog.active) return
|
||||
setState("restoreFocus", false)
|
||||
button?.focus()
|
||||
})
|
||||
|
||||
function choose() {
|
||||
if (move.isPending) return
|
||||
pickDirectory({
|
||||
server: server.conn,
|
||||
title: language.t("session.location.choose"),
|
||||
onSelect: (result) => {
|
||||
const selected = Array.isArray(result) ? result[0] : result
|
||||
|
||||
if (selected) move.mutate({ directory: selected })
|
||||
|
||||
if (!selected) setState("restoreFocus", true)
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<div data-component="session-location-missing">
|
||||
<DockShell class="flex flex-col gap-2 p-3">
|
||||
<div role="status" class="flex items-start gap-2 text-13-regular leading-[var(--line-height-base)]">
|
||||
<Icon name="warning" class="shrink-0 text-icon-warning-base" />
|
||||
<div class="min-w-0 flex flex-col gap-1">
|
||||
<div class="font-medium text-text-strong">{language.t("session.location.unavailable")}</div>
|
||||
<div class="break-all font-mono text-12-regular text-text-weak">{props.directory}</div>
|
||||
<div class="text-text-base">{language.t("session.location.description")}</div>
|
||||
</div>
|
||||
</div>
|
||||
</DockShell>
|
||||
<DockTray attach="top" class="flex flex-wrap justify-end gap-2 p-2 pt-[22px]">
|
||||
<Show when={project()?.vcs === "git"}>
|
||||
<Menu placement="top-end" onOpenChange={(open) => setState("worktreesOpen", open)}>
|
||||
<Menu.Trigger as={Button} variant="neutral" disabled={move.isPending}>
|
||||
{language.t("session.location.worktree")}
|
||||
</Menu.Trigger>
|
||||
<Menu.Portal>
|
||||
<Menu.Content class="max-h-80 max-w-[calc(100vw-32px)] overflow-y-auto">
|
||||
<Menu.Item onSelect={() => move.mutate({})} disabled={move.isPending}>
|
||||
<Icon name="workspace-new" />
|
||||
{language.t("workspace.new")}
|
||||
</Menu.Item>
|
||||
<Show when={worktrees.loading && otherWorktrees().length === 0}>
|
||||
<Menu.Item disabled>{language.t("common.loading")}</Menu.Item>
|
||||
</Show>
|
||||
<For each={otherWorktrees()}>
|
||||
{(worktree) => (
|
||||
<Menu.Item
|
||||
title={worktree.directory}
|
||||
onSelect={() => move.mutate({ directory: worktree.directory })}
|
||||
disabled={move.isPending}
|
||||
>
|
||||
<Icon name="workspace-isolated" />
|
||||
<span class="truncate">{getFilename(worktree.directory)}</span>
|
||||
</Menu.Item>
|
||||
)}
|
||||
</For>
|
||||
</Menu.Content>
|
||||
</Menu.Portal>
|
||||
</Menu>
|
||||
</Show>
|
||||
<Button ref={button} variant="contrast" onClick={choose} disabled={move.isPending}>
|
||||
{language.t(move.isPending ? "session.location.moving" : "session.location.choose")}
|
||||
</Button>
|
||||
</DockTray>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,10 +1,10 @@
|
||||
import type { SessionUserActions } from "@opencode/session-ui/message"
|
||||
import { getFilename } from "@opencode/util/path"
|
||||
import { getFilename, sameDirectory } from "@opencode/util/path"
|
||||
import { useDialog } from "@opencode/ui/context/dialog"
|
||||
import { isScrollKeyTarget, scrollKey, scrollKeyOwner } from "@opencode/ui/scroll-view"
|
||||
import { makeEventListener } from "@solid-primitives/event-listener"
|
||||
import { useNavigate } from "@solidjs/router"
|
||||
import { createEffect, createMemo, on, onMount, type Accessor } from "solid-js"
|
||||
import { createEffect, createMemo, on, onMount, Show, type Accessor } from "solid-js"
|
||||
import { Composer } from "@/composer/composer"
|
||||
import { useComposerState } from "@/composer/persistence"
|
||||
import { createComposerControls } from "@/composer/selection"
|
||||
@@ -32,6 +32,7 @@ import { SessionQueuePanel } from "./queue-panel"
|
||||
import { resolveSessionComposerSelection } from "./selection"
|
||||
import { createSessionRequestModel } from "../requests/model"
|
||||
import type { Region } from "@/runtime/extension/panels"
|
||||
import { SessionLocationMissing } from "./location-missing"
|
||||
|
||||
export function createActiveSessionRegion(input: {
|
||||
session: SessionModel
|
||||
@@ -285,24 +286,51 @@ export function createActiveSessionRegion(input: {
|
||||
export type ActiveSessionRegionModel = ReturnType<typeof createActiveSessionRegion>
|
||||
|
||||
export function ActiveSessionComposerRegion(props: {
|
||||
session: SessionModel
|
||||
model: SessionComposerController
|
||||
suggestionBoundary: () => HTMLElement | undefined
|
||||
}) {
|
||||
const location = useWorkspaceLocation()
|
||||
|
||||
// Only the server's LocationNotFoundError for the session's own Location replaces the composer.
|
||||
const missing = createMemo(() => {
|
||||
const ref = location().missing
|
||||
const info = props.session.data.info()
|
||||
|
||||
if (!ref || !info || !sameDirectory(info.location.directory, ref.directory)) return
|
||||
|
||||
return { directory: info.location.directory, sessionID: info.id, projectID: info.projectID }
|
||||
})
|
||||
|
||||
return (
|
||||
<SessionComposerRegion
|
||||
controller={props.model.region}
|
||||
composer={
|
||||
<div class="relative">
|
||||
<SessionQueuePanel queue={props.model.queue} />
|
||||
<div class="relative z-10">
|
||||
<Composer
|
||||
model={props.model.composer}
|
||||
borderUnderlay
|
||||
readOnly={props.model.queue.undoing()}
|
||||
suggestionBoundary={props.suggestionBoundary}
|
||||
<Show
|
||||
when={missing()}
|
||||
keyed
|
||||
fallback={
|
||||
<div class="relative">
|
||||
<SessionQueuePanel queue={props.model.queue} />
|
||||
<div class="relative z-10">
|
||||
<Composer
|
||||
model={props.model.composer}
|
||||
borderUnderlay
|
||||
readOnly={props.model.queue.undoing()}
|
||||
suggestionBoundary={props.suggestionBoundary}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
{(current) => (
|
||||
<SessionLocationMissing
|
||||
sessionID={current.sessionID}
|
||||
projectID={current.projectID}
|
||||
directory={current.directory}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Show>
|
||||
}
|
||||
/>
|
||||
)
|
||||
|
||||
@@ -139,7 +139,7 @@ function ResolvedTargetSessionRoute() {
|
||||
>
|
||||
<Show when={directory()} fallback={<PendingSessionState sessionID={params.id} />}>
|
||||
{(value) => (
|
||||
<LocationProvider directory={value}>
|
||||
<LocationProvider directory={value()}>
|
||||
<SessionUIProvider directory={value()} server={server.key}>
|
||||
<TargetSessionPage />
|
||||
</SessionUIProvider>
|
||||
|
||||
@@ -341,7 +341,9 @@ function SessionScreenContent(props: {
|
||||
</div>
|
||||
|
||||
<Show when={composer.active()} keyed>
|
||||
{(model) => <ActiveSessionComposerRegion model={model} suggestionBoundary={timeline.scroller} />}
|
||||
{(model) => (
|
||||
<ActiveSessionComposerRegion session={session} model={model} suggestionBoundary={timeline.scroller} />
|
||||
)}
|
||||
</Show>
|
||||
</>
|
||||
)
|
||||
|
||||
@@ -1,33 +1,40 @@
|
||||
import { createSimpleContext } from "@opencode/ui/context"
|
||||
import type { LocationGetOutput, LocationRef } from "@opencode/client/promise"
|
||||
import { isLocationNotFoundError, type LocationGetOutput, type LocationRef } from "@opencode/client/promise"
|
||||
import { retry } from "@opencode/util/retry"
|
||||
import { type Accessor, createEffect, createMemo, onCleanup } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { type LocationContext, useServerSDK } from "@/runtime/server/client"
|
||||
import { useData, useServer } from "@/runtime/server/current"
|
||||
|
||||
export type { LocationContext } from "@/runtime/server/client"
|
||||
|
||||
export type WorkspaceLocation = LocationContext & {
|
||||
readonly ref: LocationRef
|
||||
readonly current: LocationGetOutput | undefined
|
||||
/** Set only when the server reports that this exact Location's folder does not exist. */
|
||||
readonly missing: LocationRef | undefined
|
||||
}
|
||||
|
||||
const context = createSimpleContext({
|
||||
name: "Location",
|
||||
init: (props: { directory: string | Accessor<string>; workspaceID?: string | Accessor<string | undefined> }) => {
|
||||
init: (props: { directory: string; workspaceID?: string }) => {
|
||||
const serverSDK = useServerSDK()
|
||||
const server = useServer()
|
||||
const data = useData()
|
||||
|
||||
const ref = createMemo(
|
||||
() => ({
|
||||
directory: typeof props.directory === "function" ? props.directory() : props.directory,
|
||||
workspaceID: typeof props.workspaceID === "function" ? props.workspaceID() : props.workspaceID,
|
||||
directory: props.directory,
|
||||
workspaceID: props.workspaceID,
|
||||
}),
|
||||
undefined,
|
||||
{
|
||||
equals: (previous, next) => previous.directory === next.directory && previous.workspaceID === next.workspaceID,
|
||||
},
|
||||
)
|
||||
|
||||
const current = createMemo(() => data.location.info(ref()))
|
||||
const [state, setState] = createStore<{ missing?: LocationRef }>({})
|
||||
|
||||
createEffect(() => {
|
||||
const location = ref()
|
||||
@@ -35,27 +42,37 @@ const context = createSimpleContext({
|
||||
onCleanup(() => {
|
||||
stale = true
|
||||
})
|
||||
setState("missing", undefined)
|
||||
|
||||
if (serverSDK.connection.status() !== "connected") return
|
||||
// A failed sync does not prove the directory is missing. Keep recovery local to reads.
|
||||
// Generic sync failures do not prove the directory is missing; only the server's typed
|
||||
// LocationNotFoundError does, and retrying it cannot succeed until the folder changes.
|
||||
void retry(() => (stale ? Promise.resolve() : data.location.sync(location)), {
|
||||
retryIf: () => !stale,
|
||||
}).catch(() => undefined)
|
||||
retryIf: (error) => !stale && !isLocationNotFoundError(error),
|
||||
}).catch((error) => {
|
||||
if (stale || !isLocationNotFoundError(error)) return
|
||||
setState("missing", location)
|
||||
})
|
||||
})
|
||||
createEffect(() => {
|
||||
const id = current()?.project.id
|
||||
|
||||
if (!id || serverSDK.connection.status() !== "connected") return
|
||||
// Showing a Location is the demand for its project's worktree inventory (workspace styling, picker).
|
||||
void server.ctx.sync.worktrees.list(id).then(() => server.ctx.sync.worktrees.refresh(id))
|
||||
})
|
||||
|
||||
const location = createMemo(() => serverSDK.ensureDirSdkContext(current()?.directory ?? ref().directory))
|
||||
|
||||
return createMemo<WorkspaceLocation>(() => ({
|
||||
...location(),
|
||||
ref: ref(),
|
||||
current: current(),
|
||||
missing: state.missing,
|
||||
}))
|
||||
},
|
||||
})
|
||||
|
||||
export const useWorkspaceLocation: () => Accessor<WorkspaceLocation> = context.use
|
||||
|
||||
export const LocationProvider = context.provider
|
||||
|
||||
@@ -49,10 +49,12 @@ export function DirectoryPickerDialog(props: DirectoryPickerDialogProps) {
|
||||
const dialog = useDialog()
|
||||
const language = useLanguage()
|
||||
const policy = pickerMode(props.mode ?? "directory", props.start)
|
||||
|
||||
const action = {
|
||||
file: language.t("dialog.directory.action.selectFile"),
|
||||
directory: language.t("dialog.directory.action.selectFolder"),
|
||||
}
|
||||
|
||||
const [root, setRoot] = createSignal("")
|
||||
const [input, setInput] = createSignal("")
|
||||
const [selected, setSelected] = createSignal("")
|
||||
@@ -74,31 +76,42 @@ export function DirectoryPickerDialog(props: DirectoryPickerDialogProps) {
|
||||
() => sdk.api.location.get().catch(() => undefined),
|
||||
{ initialValue: undefined },
|
||||
)
|
||||
|
||||
const home = createMemo(() => sync.data.path.home || "")
|
||||
|
||||
const location = createMemo(() => {
|
||||
const current = props.location ?? fallbackPath()
|
||||
const current = props.location ?? fallbackPath.latest
|
||||
|
||||
return current ? { directory: current.directory } : undefined
|
||||
})
|
||||
|
||||
const start = createMemo(
|
||||
() =>
|
||||
props.start ||
|
||||
sync.data.path.home ||
|
||||
props.location?.directory ||
|
||||
sync.data.path.directory ||
|
||||
fallbackPath()?.directory,
|
||||
fallbackPath.latest?.directory,
|
||||
)
|
||||
|
||||
const search = createDirectorySearch({ sdk, home, location, base: () => root() || start() })
|
||||
|
||||
const [suggestions] = createResource(input, async (value) => {
|
||||
const cleaned = cleanPickerInput(value)
|
||||
const typed = cleaned.replace(/\/+$/, "")
|
||||
const current = displayPickerPath(root(), value, home()).replace(/\/+$/, "")
|
||||
|
||||
if (!cleaned || (root() && typed === current)) return { query: value, items: [] }
|
||||
const directories = (await search(value)).map((absolute) => ({ absolute, type: "directory" as const }))
|
||||
|
||||
if (!policy.includeFiles) return { query: value, items: directories.slice(0, 5) }
|
||||
const base = location()?.directory
|
||||
|
||||
if (!base) return { query: value, items: directories.slice(0, 5) }
|
||||
const query = pickerRelativePath(base, pickerAbsoluteInput(cleaned, home(), root() || base))
|
||||
|
||||
if (query === undefined) return { query: value, items: directories.slice(0, 5) }
|
||||
|
||||
const files = await sdk.api.file
|
||||
.find({
|
||||
location: location(),
|
||||
@@ -108,48 +121,64 @@ export function DirectoryPickerDialog(props: DirectoryPickerDialogProps) {
|
||||
})
|
||||
.then((result) => result.data)
|
||||
.catch(() => [])
|
||||
|
||||
const results = [
|
||||
...directories,
|
||||
...files.map((entry) => ({ absolute: pickerAbsolutePath(entry.path, base), type: "file" as const })),
|
||||
]
|
||||
|
||||
return {
|
||||
query: value,
|
||||
items: Array.from(new Map(results.map((result) => [result.absolute, result])).values()).slice(0, 8),
|
||||
}
|
||||
})
|
||||
const currentSuggestions = createMemo(() => currentPickerSuggestions(suggestions(), input()))
|
||||
|
||||
const currentSuggestions = createMemo(() => currentPickerSuggestions(suggestions.latest, input()))
|
||||
|
||||
async function load(path: string, generation: number, eager = false) {
|
||||
const key = path.replace(/\/+$/, "")
|
||||
setError(false)
|
||||
const absolute = absoluteTreePath(root(), key)
|
||||
const existing = listings.get(key)
|
||||
|
||||
if (existing && !eager) loads.promote(`${generation}:${key}`)
|
||||
|
||||
const request =
|
||||
existing ??
|
||||
loads.schedule(`${generation}:${key}`, eager ? "background" : "user", () => {
|
||||
if (!activeTreeNavigation(generation, navigation)) return Promise.resolve(undefined)
|
||||
const current = location()
|
||||
|
||||
if (!current) return Promise.resolve(undefined)
|
||||
|
||||
return listPickerDirectory(sdk, current, absolute).catch(() => undefined)
|
||||
})
|
||||
|
||||
listings.set(key, request)
|
||||
const nodes = await request
|
||||
|
||||
if (!activeTreeNavigation(generation, navigation)) return false
|
||||
|
||||
if (!nodes) {
|
||||
listings.delete(key)
|
||||
|
||||
if (!key) setError(true)
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
tree?.batch(policy.entries(key, nodes).map((item) => ({ type: "add", path: item })))
|
||||
|
||||
if (!eager && advanceTreePreload(advanced, key)) {
|
||||
for (const directory of preloadTreeDirectories(key, nodes)) void load(directory, generation, true)
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
async function navigate(path: string) {
|
||||
const value = policy.navigation(pickerAbsoluteInput(cleanPickerInput(path), home(), root() || start() || home()))
|
||||
|
||||
if (!value) return
|
||||
const token = ++navigation
|
||||
setLoading(true)
|
||||
@@ -163,6 +192,7 @@ export function DirectoryPickerDialog(props: DirectoryPickerDialogProps) {
|
||||
advanced.clear()
|
||||
tree?.resetPaths([])
|
||||
const valid = await load("", token)
|
||||
|
||||
if (!activeTreeNavigation(token, navigation)) return
|
||||
setRootValid(valid)
|
||||
setLoading(false)
|
||||
@@ -171,9 +201,11 @@ export function DirectoryPickerDialog(props: DirectoryPickerDialogProps) {
|
||||
function complete() {
|
||||
const items = currentSuggestions()
|
||||
const match = items[activeSuggestion()] ?? items[0]
|
||||
|
||||
if (!match) return
|
||||
const value = displayPickerPath(match.absolute, input(), home())
|
||||
setInput(match.type === "directory" && !value.endsWith("/") ? value + "/" : value)
|
||||
|
||||
if (match.type === "file") {
|
||||
setSelected(policy.selection(root(), pickerFileSearchQuery(root(), match.absolute, home())) ?? "")
|
||||
setSuggestionsOpen(false)
|
||||
@@ -184,8 +216,10 @@ export function DirectoryPickerDialog(props: DirectoryPickerDialogProps) {
|
||||
function chooseSuggestion(suggestion: { absolute: string; type: "file" | "directory" }) {
|
||||
if (suggestion.type === "directory") {
|
||||
void navigate(suggestion.absolute)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
setInput(displayPickerPath(suggestion.absolute, input(), home()))
|
||||
setSelected(policy.selection(root(), pickerFileSearchQuery(root(), suggestion.absolute, home())) ?? "")
|
||||
setSuggestionsOpen(false)
|
||||
@@ -199,23 +233,31 @@ export function DirectoryPickerDialog(props: DirectoryPickerDialogProps) {
|
||||
|
||||
function activeSuggestionValue() {
|
||||
const items = currentSuggestions()
|
||||
|
||||
return items[activeSuggestion()] ?? items[0]
|
||||
}
|
||||
|
||||
const keyActions: Partial<Record<string, () => void>> = {
|
||||
ArrowDown: () => moveSuggestion(1),
|
||||
ArrowUp: () => moveSuggestion(-1),
|
||||
Enter: () => {
|
||||
const suggestion = activeSuggestionValue()
|
||||
if (suggestion) chooseSuggestion(suggestion)
|
||||
if (!suggestion) void navigate(input())
|
||||
},
|
||||
Tab: complete,
|
||||
}
|
||||
const keyActions = new Map([
|
||||
["ArrowDown", () => moveSuggestion(1)],
|
||||
["ArrowUp", () => moveSuggestion(-1)],
|
||||
[
|
||||
"Enter",
|
||||
() => {
|
||||
const suggestion = activeSuggestionValue()
|
||||
|
||||
if (suggestion) chooseSuggestion(suggestion)
|
||||
|
||||
if (!suggestion) void navigate(input())
|
||||
},
|
||||
],
|
||||
["Tab", complete],
|
||||
])
|
||||
|
||||
function handleInputKey(event: KeyboardEvent) {
|
||||
const action = keyActions[event.key]
|
||||
const action = keyActions.get(event.key)
|
||||
|
||||
if (!action) return
|
||||
|
||||
if (event.key === "Tab" && event.shiftKey) return
|
||||
event.preventDefault()
|
||||
action()
|
||||
@@ -223,6 +265,7 @@ export function DirectoryPickerDialog(props: DirectoryPickerDialogProps) {
|
||||
|
||||
function resolve() {
|
||||
const path = policy.result(root(), selected(), rootValid())
|
||||
|
||||
if (!path) return
|
||||
props.onSelect(props.multiple ? [path] : path)
|
||||
dialog.close()
|
||||
@@ -230,10 +273,11 @@ export function DirectoryPickerDialog(props: DirectoryPickerDialogProps) {
|
||||
|
||||
onMount(() => {
|
||||
const closeSuggestions = (event: PointerEvent) => {
|
||||
if (pathArea?.contains(event.target as Node)) return
|
||||
if (event.target instanceof Node && pathArea?.contains(event.target)) return
|
||||
setSuggestionsOpen(false)
|
||||
setActiveSuggestion(-1)
|
||||
}
|
||||
|
||||
document.addEventListener("pointerdown", closeSuggestions)
|
||||
onCleanup(() => document.removeEventListener("pointerdown", closeSuggestions))
|
||||
tree = new FileTree({
|
||||
@@ -266,6 +310,7 @@ export function DirectoryPickerDialog(props: DirectoryPickerDialogProps) {
|
||||
setSelected(path ? (policy.selection(root(), path) ?? "") : "")
|
||||
},
|
||||
})
|
||||
|
||||
if (!container) return
|
||||
tree.render({ containerWrapper: container })
|
||||
tree.getFileTreeContainer()?.classList.add("directory-picker-tree")
|
||||
@@ -273,6 +318,7 @@ export function DirectoryPickerDialog(props: DirectoryPickerDialogProps) {
|
||||
|
||||
createEffect(() => {
|
||||
const path = start()
|
||||
|
||||
if (!path || !location() || root()) return
|
||||
void navigate(path)
|
||||
})
|
||||
@@ -347,13 +393,16 @@ export function DirectoryPickerDialog(props: DirectoryPickerDialogProps) {
|
||||
const scroller = tree
|
||||
?.getFileTreeContainer()
|
||||
?.shadowRoot?.querySelector<HTMLElement>("[data-file-tree-virtualized-scroll]")
|
||||
|
||||
if (!scroller) return
|
||||
|
||||
const next = nextTreeScrollTop(
|
||||
scroller.scrollTop,
|
||||
event.deltaY,
|
||||
scroller.scrollHeight,
|
||||
scroller.clientHeight,
|
||||
)
|
||||
|
||||
if (next === scroller.scrollTop) return
|
||||
event.preventDefault()
|
||||
scroller.scrollTop = next
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useDialog } from "@opencode/ui/context/dialog"
|
||||
import { ServerConnection } from "@/runtime/server/registry"
|
||||
import { usePlatform } from "@/runtime/platform/platform"
|
||||
import { lazy } from "solid-js"
|
||||
import { lazy, Suspense } from "solid-js"
|
||||
import type { LocationRef } from "@opencode/client/promise"
|
||||
import { directoryPickerKind } from "./policy"
|
||||
|
||||
@@ -24,17 +24,29 @@ export function useDirectoryPicker() {
|
||||
return (input: DirectoryPickerInput) => {
|
||||
if (directoryPickerKind(platform.platform, input.server) === "native" && platform.platform === "desktop") {
|
||||
void platform.openDirectoryPickerDialog({ title: input.title, multiple: input.multiple }).then(input.onSelect)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
let selected = false
|
||||
|
||||
const onSelect = (result: string | string[] | null) => {
|
||||
selected = result !== null
|
||||
input.onSelect(result)
|
||||
}
|
||||
|
||||
const cancel = () => {
|
||||
if (!selected) input.onSelect(null)
|
||||
}
|
||||
dialog.show(() => <DirectoryPickerDialog {...input} onSelect={onSelect} />, cancel)
|
||||
|
||||
// Dialogs render under the caller's owner, so loading the lazy chunk would otherwise suspend the caller's boundary.
|
||||
dialog.show(
|
||||
() => (
|
||||
<Suspense>
|
||||
<DirectoryPickerDialog {...input} onSelect={onSelect} />
|
||||
</Suspense>
|
||||
),
|
||||
cancel,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user