mirror of
https://github.com/anomalyco/opencode.git
synced 2026-11-13 19:46:16 +03:00
feat(app): refresh session location dock (#56087)
This commit is contained in:
committed by
GitHub
Unverified
parent
3a58a0c9cf
commit
9a7bfb063c
@@ -107,7 +107,8 @@ test("replaces the composer when the location is not found and recovers by movin
|
||||
})
|
||||
|
||||
await page.goto(`/server/${base64Encode(fixture.serverKey)}/session/${sessionID}`)
|
||||
const status = page.getByRole("status").filter({ hasText: "Session location unavailable" })
|
||||
const status = page.getByRole("group", { name: "Session location unavailable", exact: true })
|
||||
await expect(status).toHaveAccessibleDescription(`${directory} Choose another directory to continue this session.`)
|
||||
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)
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
[data-component="session-location-missing"] {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
font-family: var(--v2-font-family-sans);
|
||||
|
||||
[data-slot="location-body"] {
|
||||
padding: 8px;
|
||||
background-color: var(--v2-background-bg-base);
|
||||
border-radius: 10px;
|
||||
box-shadow: var(--v2-elevation-raised);
|
||||
}
|
||||
|
||||
[data-slot="location-status"] {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
padding: 4px;
|
||||
|
||||
> [data-slot="icon-svg"] {
|
||||
flex-shrink: 0;
|
||||
color: light-dark(var(--v2-yellow-800), var(--v2-yellow-700));
|
||||
}
|
||||
}
|
||||
|
||||
[data-slot="location-details"] {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
min-inline-size: 0;
|
||||
}
|
||||
|
||||
[data-slot="location-title"] {
|
||||
font-size: 13px;
|
||||
font-weight: 530;
|
||||
line-height: var(--line-height-base);
|
||||
color: var(--v2-text-text-base);
|
||||
}
|
||||
|
||||
[data-slot="location-path"] {
|
||||
font-family: var(--v2-font-family-code);
|
||||
font-size: 12px;
|
||||
line-height: var(--line-height-base);
|
||||
color: var(--v2-text-text-faint);
|
||||
overflow-wrap: anywhere;
|
||||
-webkit-user-select: text;
|
||||
user-select: text;
|
||||
}
|
||||
|
||||
[data-slot="location-description"] {
|
||||
font-size: 13px;
|
||||
font-weight: 440;
|
||||
line-height: var(--line-height-base);
|
||||
color: var(--v2-text-text-muted);
|
||||
}
|
||||
|
||||
[data-slot="location-footer"] {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
box-sizing: border-box;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
padding-block: 16px 8px;
|
||||
padding-inline: 12px;
|
||||
margin-block-start: -9px;
|
||||
border: 0.5px solid var(--v2-border-border-base);
|
||||
border-start-start-radius: 0;
|
||||
border-start-end-radius: 0;
|
||||
border-end-start-radius: 10px;
|
||||
border-end-end-radius: 10px;
|
||||
background-color: var(--v2-background-bg-layer-01);
|
||||
}
|
||||
}
|
||||
@@ -2,16 +2,19 @@ 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 { Loader } from "@opencode/ui/loader"
|
||||
import { Menu } from "@opencode/ui/menu"
|
||||
import { getFilename, sameDirectory } from "@opencode/util/path"
|
||||
import { makeEventListener } from "@solid-primitives/event-listener"
|
||||
import { useMutation } from "@tanstack/solid-query"
|
||||
import { createEffect, createResource, For, Show } from "solid-js"
|
||||
import { createEffect, createResource, createUniqueId, 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"
|
||||
import "./location-missing.css"
|
||||
|
||||
export function SessionLocationMissing(props: { sessionID: string; projectID: string; directory: string }) {
|
||||
const language = useLanguage()
|
||||
@@ -19,7 +22,7 @@ export function SessionLocationMissing(props: { sessionID: string; projectID: st
|
||||
const data = useData()
|
||||
const dialog = useDialog()
|
||||
const pickDirectory = useDirectoryPicker()
|
||||
const [state, setState] = createStore({ restoreFocus: false, worktreesOpen: false })
|
||||
const [state, setState] = createStore({ restoreFocus: false, worktreesOpen: false, search: "" })
|
||||
const project = () => data.project.get(props.projectID)
|
||||
|
||||
const [worktrees] = createResource(
|
||||
@@ -39,10 +42,22 @@ export function SessionLocationMissing(props: { sessionID: string; projectID: st
|
||||
const otherWorktrees = () =>
|
||||
worktrees.latest.filter((item) => item.strategy && !sameDirectory(item.directory, props.directory))
|
||||
|
||||
const searchable = () => otherWorktrees().length >= 10
|
||||
|
||||
const visibleWorktrees = () => {
|
||||
const query = state.search.trim().toLowerCase()
|
||||
const matches = query
|
||||
? otherWorktrees().filter((item) => getFilename(item.directory).toLowerCase().includes(query))
|
||||
: otherWorktrees()
|
||||
|
||||
return matches.slice(0, 10)
|
||||
}
|
||||
|
||||
let button: HTMLButtonElement | undefined
|
||||
let searchInput: HTMLInputElement | undefined
|
||||
|
||||
const move = useMutation(() => ({
|
||||
mutationFn: async (input: { directory?: string }) => {
|
||||
mutationFn: async (input: { source: "worktree" | "directory"; directory?: string }) => {
|
||||
// The missing worktree cannot resolve its own Location, so create from the project's saved checkout.
|
||||
const current = project()
|
||||
|
||||
@@ -69,6 +84,9 @@ export function SessionLocationMissing(props: { sessionID: string; projectID: st
|
||||
},
|
||||
}))
|
||||
|
||||
const pending = (source: "worktree" | "directory") => move.isPending && move.variables?.source === source
|
||||
const id = createUniqueId()
|
||||
|
||||
createEffect(() => {
|
||||
if (!state.restoreFocus || move.isPending || dialog.active) return
|
||||
setState("restoreFocus", false)
|
||||
@@ -83,7 +101,7 @@ export function SessionLocationMissing(props: { sessionID: string; projectID: st
|
||||
onSelect: (result) => {
|
||||
const selected = Array.isArray(result) ? result[0] : result
|
||||
|
||||
if (selected) move.mutate({ directory: selected })
|
||||
if (selected) move.mutate({ source: "directory", directory: selected })
|
||||
|
||||
if (!selected) setState("restoreFocus", true)
|
||||
},
|
||||
@@ -92,40 +110,117 @@ export function SessionLocationMissing(props: { sessionID: string; projectID: st
|
||||
|
||||
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>
|
||||
<DockShell data-slot="location-body">
|
||||
<div
|
||||
role="group"
|
||||
aria-labelledby={`${id}-title`}
|
||||
aria-describedby={`${id}-path ${id}-description`}
|
||||
data-slot="location-status"
|
||||
>
|
||||
<Icon name="warning" />
|
||||
<div data-slot="location-details">
|
||||
<div id={`${id}-title`} role="status" data-slot="location-title">
|
||||
{language.t("session.location.unavailable")}
|
||||
</div>
|
||||
<div id={`${id}-path`} data-slot="location-path">
|
||||
{props.directory}
|
||||
</div>
|
||||
<div id={`${id}-description`} data-slot="location-description">
|
||||
{language.t("session.location.description")}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</DockShell>
|
||||
<DockTray attach="top" class="flex flex-wrap justify-end gap-2 p-2 pt-[22px]">
|
||||
<DockTray data-slot="location-footer">
|
||||
<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 placement="top-end" onOpenChange={(open) => setState({ worktreesOpen: open, search: "" })}>
|
||||
<Menu.Trigger
|
||||
as={Button}
|
||||
variant={pending("worktree") ? "loading" : "neutral"}
|
||||
size="large"
|
||||
disabled={move.isPending}
|
||||
>
|
||||
<Show when={pending("worktree")}>
|
||||
<Loader />
|
||||
</Show>
|
||||
{language.t(pending("worktree") ? "session.location.moving" : "session.location.worktree")}
|
||||
<Show when={!pending("worktree")}>
|
||||
<Icon name="chevron-down" size="small" />
|
||||
</Show>
|
||||
</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.Content
|
||||
ref={(element: HTMLElement) =>
|
||||
// Hovering an item moves focus to it, so send typing back to the search field before type-ahead runs.
|
||||
makeEventListener(
|
||||
element,
|
||||
"keydown",
|
||||
(event) => {
|
||||
if (!searchInput || event.target === searchInput) return
|
||||
if (event.ctrlKey || event.metaKey || event.altKey) return
|
||||
if (event.key.length !== 1 && event.key !== "Backspace") return
|
||||
if (event.key === " " && !state.search) return
|
||||
event.stopPropagation()
|
||||
searchInput.focus({ preventScroll: true })
|
||||
},
|
||||
{ capture: true },
|
||||
)
|
||||
}
|
||||
class="max-h-[66.667dvh] w-[200px] overflow-y-auto"
|
||||
>
|
||||
<Menu.Item onSelect={() => move.mutate({ source: "worktree" })} disabled={move.isPending}>
|
||||
<Icon name="plus" />
|
||||
<span class="min-w-0 flex-1 truncate">{language.t("workspace.new")}</span>
|
||||
</Menu.Item>
|
||||
<Show when={worktrees.loading || otherWorktrees().length > 0}>
|
||||
<Menu.Separator class="h-[0.5px]" />
|
||||
</Show>
|
||||
<Show when={worktrees.loading && otherWorktrees().length === 0}>
|
||||
<Menu.Item disabled>{language.t("common.loading")}</Menu.Item>
|
||||
</Show>
|
||||
<For each={otherWorktrees()}>
|
||||
<Show when={searchable()}>
|
||||
<div class="flex h-7 items-center gap-2 rounded-sm ps-3 pe-2 text-v2-icon-icon-muted">
|
||||
<Icon name="magnifying-glass" size="small" class="shrink-0" />
|
||||
<input
|
||||
// Worktrees load after the menu opens, so focus the field once it mounts.
|
||||
ref={(element) => {
|
||||
searchInput = element
|
||||
requestAnimationFrame(() =>
|
||||
requestAnimationFrame(() => element.focus({ preventScroll: true })),
|
||||
)
|
||||
}}
|
||||
value={state.search}
|
||||
placeholder={language.t("session.new.workspace.search.placeholder")}
|
||||
aria-label={language.t("session.new.workspace.search.placeholder")}
|
||||
class="h-7 min-w-0 flex-1 border-0 bg-transparent text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-base outline-none placeholder:text-v2-text-text-faint"
|
||||
onInput={(event) => setState("search", event.currentTarget.value)}
|
||||
onKeyDown={(event) => {
|
||||
if (
|
||||
event.key === "Escape" ||
|
||||
event.key === "ArrowDown" ||
|
||||
event.key === "ArrowUp" ||
|
||||
event.key === "Enter"
|
||||
)
|
||||
return
|
||||
event.stopPropagation()
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</Show>
|
||||
<Show when={state.search.trim() && visibleWorktrees().length === 0}>
|
||||
<div class="px-3 py-4 text-center text-[13px] font-[440] leading-5 text-v2-text-text-muted">
|
||||
{language.t("session.new.workspace.search.empty")}
|
||||
</div>
|
||||
</Show>
|
||||
<For each={visibleWorktrees()}>
|
||||
{(worktree) => (
|
||||
<Menu.Item
|
||||
title={worktree.directory}
|
||||
onSelect={() => move.mutate({ directory: worktree.directory })}
|
||||
onSelect={() => move.mutate({ source: "worktree", directory: worktree.directory })}
|
||||
disabled={move.isPending}
|
||||
>
|
||||
<Icon name="workspace-isolated" />
|
||||
<span class="truncate">{getFilename(worktree.directory)}</span>
|
||||
<Icon name="outline-worktree" />
|
||||
<span class="min-w-0 flex-1 truncate">{getFilename(worktree.directory)}</span>
|
||||
</Menu.Item>
|
||||
)}
|
||||
</For>
|
||||
@@ -133,8 +228,17 @@ export function SessionLocationMissing(props: { sessionID: string; projectID: st
|
||||
</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
|
||||
ref={button}
|
||||
variant={pending("directory") ? "loading" : "submit"}
|
||||
size="large"
|
||||
onClick={choose}
|
||||
disabled={move.isPending}
|
||||
>
|
||||
<Show when={pending("directory")}>
|
||||
<Loader />
|
||||
</Show>
|
||||
{language.t(pending("directory") ? "session.location.moving" : "session.location.choose")}
|
||||
</Button>
|
||||
</DockTray>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user