mirror of
https://github.com/anomalyco/opencode.git
synced 2026-11-13 19:46:16 +03:00
feat(app): simplify new session location row
Show one clickable project/worktree pill aligned to the composer grid, with the session path right-aligned. Remove the base-branch selector; new worktrees start from the current branch.
This commit is contained in:
@@ -2,6 +2,8 @@ import { useDialog } from "@opencode/ui/context/dialog"
|
||||
import { Tooltip } from "@opencode/ui/tooltip"
|
||||
import { Icon } from "@opencode/ui/icon"
|
||||
import { Show, createMemo, createSignal } from "solid-js"
|
||||
import { createResizeObserver } from "@solid-primitives/resize-observer"
|
||||
import { getFilename } from "@opencode/util/path"
|
||||
import {
|
||||
displayName,
|
||||
getProjectAvatarSource,
|
||||
@@ -13,7 +15,6 @@ import createPresence from "solid-presence"
|
||||
import { Composer } from "@/composer/composer"
|
||||
import { ComposerDropzone } from "@/composer/dropzone"
|
||||
import type { ComposerModel } from "@/composer/model"
|
||||
import { PromptBranchSelector } from "@/new-session/workspace/selector"
|
||||
import {
|
||||
PromptProjectAddButton,
|
||||
type PromptProject,
|
||||
@@ -78,14 +79,17 @@ export function NewSessionView(props: {
|
||||
<div class={NEW_SESSION_CONTENT_WIDTH}>
|
||||
<NewSessionWordmark ref={setWordmark} />
|
||||
<div class="mt-8 flex flex-col gap-3">
|
||||
<Composer model={props.composer} />
|
||||
<Show when={props.project.empty()}>
|
||||
<PromptProjectAddButton controller={props.project} />
|
||||
</Show>
|
||||
<Show when={props.project.selected()}>
|
||||
{(project) => (
|
||||
<div class="flex min-h-7 min-w-0 flex-row flex-wrap items-center gap-0 ps-2.5 text-v2-text-text-faint">
|
||||
<NewSessionLocationTrigger
|
||||
<div
|
||||
data-slot="new-session-composer"
|
||||
classList={{
|
||||
"rounded-[14px] bg-v2-background-bg-layer-01 shadow-[inset_0_0_0_0.5px_var(--v2-border-border-muted)]":
|
||||
!!props.project.selected(),
|
||||
}}
|
||||
>
|
||||
<Composer model={props.composer} />
|
||||
<Show when={props.project.selected()}>
|
||||
{(project) => (
|
||||
<NewSessionLocation
|
||||
project={project()}
|
||||
workspace={props.workspace.bar.visible() ? props.workspace.selection.value() : "main"}
|
||||
root={props.workspace.project.root()}
|
||||
@@ -98,25 +102,11 @@ export function NewSessionView(props: {
|
||||
})
|
||||
}
|
||||
/>
|
||||
<Show
|
||||
when={
|
||||
props.workspace.bar.visible() &&
|
||||
props.workspace.selection.value() === "create" &&
|
||||
props.workspace.bar.branch()
|
||||
}
|
||||
>
|
||||
{(branch) => (
|
||||
<PromptBranchSelector
|
||||
branch={branch()}
|
||||
branches={props.workspace.project.branches()}
|
||||
onCreate={props.workspace.selection.create}
|
||||
onSearch={props.workspace.project.searchBranches}
|
||||
onDone={props.composer.restoreFocus}
|
||||
/>
|
||||
)}
|
||||
</Show>
|
||||
</div>
|
||||
)}
|
||||
)}
|
||||
</Show>
|
||||
</div>
|
||||
<Show when={props.project.empty()}>
|
||||
<PromptProjectAddButton controller={props.project} />
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
@@ -267,7 +257,8 @@ function NewSessionTips(props: {
|
||||
)
|
||||
}
|
||||
|
||||
function NewSessionLocationTrigger(props: {
|
||||
/** The tray under the Composer: the location picker on the left and the session's directory on the right. */
|
||||
function NewSessionLocation(props: {
|
||||
project: PromptProject
|
||||
/** "main", "create", or a worktree directory. */
|
||||
workspace: string
|
||||
@@ -276,61 +267,100 @@ function NewSessionLocationTrigger(props: {
|
||||
onClick: () => void
|
||||
}) {
|
||||
const language = useLanguage()
|
||||
const [truncated, setTruncated] = createSignal(false)
|
||||
|
||||
const path = () => {
|
||||
if (props.workspace === "main") return props.root
|
||||
const worktree = () => {
|
||||
if (props.workspace === "main") return
|
||||
if (props.workspace === "create") return language.t("workspace.new")
|
||||
|
||||
return props.workspace
|
||||
return getFilename(props.workspace)
|
||||
}
|
||||
|
||||
const path = () => {
|
||||
if (props.workspace === "create") return
|
||||
|
||||
return props.workspace === "main" ? props.root : props.workspace
|
||||
}
|
||||
|
||||
return (
|
||||
<Tooltip
|
||||
appearance={props.onboarding ? "large" : undefined}
|
||||
placement="top"
|
||||
openDelay={800}
|
||||
value={
|
||||
props.onboarding ? (
|
||||
<div class="flex flex-col gap-1 text-start">
|
||||
<div class="flex items-center gap-1.5 font-[530] text-v2-text-text-base">
|
||||
<Icon name="outline-worktree" size="small" class="shrink-0 text-v2-text-text-accent" />
|
||||
<span>{language.t("workspace.onboarding.title")}</span>
|
||||
</div>
|
||||
<span class="font-[440] text-v2-text-text-muted">{language.t("workspace.onboarding.description")}</span>
|
||||
<div class="flex h-9 min-w-0 items-center gap-3 ps-2 pe-2 text-[13px] font-[440] leading-[var(--line-height-compact)] tracking-[-0.04px]">
|
||||
<div class="flex min-w-0 max-w-[60%] shrink-0 @max-[560px]:max-w-full">
|
||||
<Tooltip
|
||||
appearance={props.onboarding ? "large" : undefined}
|
||||
placement="top"
|
||||
openDelay={800}
|
||||
value={
|
||||
props.onboarding ? (
|
||||
<div class="flex flex-col gap-1 text-start">
|
||||
<div class="flex items-center gap-1.5 font-[530] text-v2-text-text-base">
|
||||
<Icon name="outline-worktree" size="small" class="shrink-0 text-v2-text-text-accent" />
|
||||
<span>{language.t("workspace.onboarding.title")}</span>
|
||||
</div>
|
||||
<span class="font-[440] text-v2-text-text-muted">{language.t("workspace.onboarding.description")}</span>
|
||||
</div>
|
||||
) : (
|
||||
language.t("session.new.workspace.trigger.tooltip")
|
||||
)
|
||||
}
|
||||
contentClass={props.onboarding ? "max-w-[280px]" : undefined}
|
||||
class="flex min-w-0 max-w-full"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
data-action="prompt-project"
|
||||
aria-haspopup="dialog"
|
||||
aria-description={language.t("session.new.workspace.trigger.tooltip")}
|
||||
class="flex h-7 min-w-0 max-w-full items-center gap-1.5 rounded-full text-start bg-v2-background-bg-layer-02 ps-1.5 pe-2.5 text-v2-text-text-muted transition-colors hover:bg-v2-background-bg-layer-03 hover:text-v2-text-text-base focus-visible:bg-v2-background-bg-layer-03 focus-visible:text-v2-text-text-base focus-visible:outline-none"
|
||||
onClick={props.onClick}
|
||||
>
|
||||
<ProjectAvatar
|
||||
class="shrink-0"
|
||||
fallback={displayName(props.project)}
|
||||
src={getProjectAvatarSource(props.project.id, props.project.icon)}
|
||||
variant={getProjectAvatarVariant(props.project.icon?.color)}
|
||||
/>
|
||||
<span class="min-w-0 truncate">{displayName(props.project)}</span>
|
||||
<Show when={worktree()}>
|
||||
{(name) => (
|
||||
<>
|
||||
<Icon name="chevron-right" class="-mx-1 shrink-0 text-v2-icon-icon-faint" />
|
||||
<span class="min-w-12 shrink-[1000] truncate">{name()}</span>
|
||||
</>
|
||||
)}
|
||||
</Show>
|
||||
<Show when={props.onboarding}>
|
||||
<span
|
||||
data-slot="workspace-onboarding-dot"
|
||||
aria-hidden="true"
|
||||
class="size-1.5 shrink-0 rounded-full bg-v2-text-text-accent"
|
||||
/>
|
||||
</Show>
|
||||
</button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<Show when={path()}>
|
||||
{(directory) => (
|
||||
<div class="flex min-w-0 flex-1 justify-end @max-[560px]:hidden">
|
||||
<Tooltip
|
||||
placement="top"
|
||||
value={directory()}
|
||||
disabled={!truncated()}
|
||||
class="flex min-w-0"
|
||||
contentClass="max-w-[calc(100vw-32px)] break-all"
|
||||
>
|
||||
<span
|
||||
ref={(element) =>
|
||||
createResizeObserver(element, () => setTruncated(element.scrollWidth > element.clientWidth))
|
||||
}
|
||||
data-slot="new-session-location-path"
|
||||
class="block min-w-0 truncate text-v2-text-text-faint [direction:rtl] [text-align:end]"
|
||||
>
|
||||
<bdi dir="ltr">{directory()}</bdi>
|
||||
</span>
|
||||
</Tooltip>
|
||||
</div>
|
||||
) : (
|
||||
language.t("session.new.workspace.trigger.tooltip")
|
||||
)
|
||||
}
|
||||
contentClass={props.onboarding ? "max-w-[280px]" : undefined}
|
||||
class="min-w-0"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
data-action="prompt-project"
|
||||
aria-haspopup="dialog"
|
||||
aria-description={language.t("session.new.workspace.trigger.tooltip")}
|
||||
class="flex h-7 min-w-0 max-w-full items-center gap-2 rounded-sm px-1.5 text-[13px] font-[440] leading-5 tracking-[-0.04px] transition-colors hover:bg-v2-overlay-simple-overlay-hover focus-visible:bg-v2-overlay-simple-overlay-hover focus-visible:outline-none"
|
||||
onClick={props.onClick}
|
||||
>
|
||||
<ProjectAvatar
|
||||
class="shrink-0"
|
||||
fallback={displayName(props.project)}
|
||||
src={getProjectAvatarSource(props.project.id, props.project.icon)}
|
||||
variant={getProjectAvatarVariant(props.project.icon?.color)}
|
||||
/>
|
||||
<span class="max-w-[200px] shrink-0 truncate text-v2-text-text-muted">{displayName(props.project)}</span>
|
||||
<span dir="ltr" class="min-w-0 truncate text-v2-text-text-faint">
|
||||
{path()}
|
||||
</span>
|
||||
<Show when={props.onboarding}>
|
||||
<span
|
||||
data-slot="workspace-onboarding-dot"
|
||||
aria-hidden="true"
|
||||
class="size-1.5 shrink-0 rounded-full bg-v2-text-text-accent"
|
||||
/>
|
||||
</Show>
|
||||
</button>
|
||||
</Tooltip>
|
||||
)}
|
||||
</Show>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { debounce } from "@solid-primitives/scheduled"
|
||||
import { createEffect, createMemo, createResource, onCleanup } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { useWorkspaceLocation } from "@/workspaces/location"
|
||||
@@ -62,12 +61,9 @@ export function createNewSessionWorkspaceController(input: {
|
||||
const tabs = useTabs()
|
||||
|
||||
const [state, setState] = createStore({
|
||||
search: "",
|
||||
existing: undefined as { projectID: string; directory: string } | undefined,
|
||||
})
|
||||
|
||||
const searchBranches = debounce((search: string) => setState("search", search.trim()), 100)
|
||||
|
||||
const currentProject = createMemo(() => {
|
||||
const projectID = data.location.info({ directory: sdk().directory })?.project.id
|
||||
const current = projectID ? data.project.get(projectID) : undefined
|
||||
@@ -175,15 +171,6 @@ export function createNewSessionWorkspaceController(input: {
|
||||
|
||||
const projectRoot = createMemo(() => currentProject()?.worktree ?? sdk().directory)
|
||||
|
||||
const [branches] = createResource(
|
||||
() => (visible() ? { directory: projectRoot(), search: state.search } : undefined),
|
||||
({ directory, search }) =>
|
||||
serverSDK.api.vcs.branch
|
||||
.list({ location: { directory }, search, limit: 50 })
|
||||
.then((response) => ({ directory, search, data: response.data }))
|
||||
.catch(() => ({ directory, search, data: [] })),
|
||||
)
|
||||
|
||||
createEffect(() => {
|
||||
void Promise.all([
|
||||
data.location.syncInfo({ directory: sdk().directory }),
|
||||
@@ -263,30 +250,12 @@ export function createNewSessionWorkspaceController(input: {
|
||||
remember,
|
||||
set: select,
|
||||
cycle: () => select(cycleNewSessionWorktree({ current: value(), existing: existing() })),
|
||||
create: (branch: string) => {
|
||||
input.setSelectedBranch(branch)
|
||||
input.setSelectedWorktree("create")
|
||||
remember("create")
|
||||
},
|
||||
},
|
||||
project: {
|
||||
root: projectRoot,
|
||||
workspaces: worktreeDirectories,
|
||||
managed: managedWorktrees,
|
||||
git: visible,
|
||||
branches: () => {
|
||||
const current = data.location.vcs.info({ directory: sdk().directory })?.branch.current
|
||||
const loaded = branches.latest
|
||||
const list = loaded?.directory === projectRoot() ? loaded.data : []
|
||||
|
||||
return [
|
||||
...new Set([
|
||||
...list,
|
||||
...(current && current.toLowerCase().includes(state.search.toLowerCase()) ? [current] : []),
|
||||
]),
|
||||
].slice(0, 50)
|
||||
},
|
||||
searchBranches,
|
||||
},
|
||||
bar: {
|
||||
visible,
|
||||
|
||||
@@ -1,129 +0,0 @@
|
||||
import { createSignal, For, Show } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { createResizeObserver } from "@solid-primitives/resize-observer"
|
||||
import { Menu } from "@opencode/ui/menu"
|
||||
import { Tooltip } from "@opencode/ui/tooltip"
|
||||
import { Icon } from "@opencode/ui/icon"
|
||||
import { useLanguage } from "@/runtime/i18n/language"
|
||||
|
||||
/** The base branch for a new worktree. */
|
||||
export function PromptBranchSelector(props: {
|
||||
branch: string
|
||||
branches: string[]
|
||||
onCreate: (branch: string) => void
|
||||
onSearch: (search: string) => void
|
||||
onDone?: () => void
|
||||
}) {
|
||||
const language = useLanguage()
|
||||
const [search, setSearch] = createStore({ branches: "" })
|
||||
const truncation = createTruncatedText()
|
||||
let input: HTMLInputElement | undefined
|
||||
let pending: string | undefined
|
||||
|
||||
return (
|
||||
<Tooltip
|
||||
placement="top"
|
||||
value={language.t("session.new.workspace.fromBranch", { branch: props.branch })}
|
||||
disabled={!truncation.truncated()}
|
||||
class="ms-1 min-w-0 max-w-[220px]"
|
||||
contentClass="max-w-[calc(100vw-32px)] break-all"
|
||||
>
|
||||
<Menu
|
||||
placement="bottom-start"
|
||||
gutter={4}
|
||||
onOpenChange={(open) => {
|
||||
if (open) {
|
||||
setSearch({ branches: "" })
|
||||
props.onSearch("")
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
const branch = pending
|
||||
pending = undefined
|
||||
|
||||
if (branch) props.onCreate(branch)
|
||||
|
||||
props.onDone?.()
|
||||
}}
|
||||
>
|
||||
<Menu.Trigger class="flex h-6 min-w-0 max-w-[220px] items-center gap-1.5 rounded-full bg-v2-background-bg-layer-02 px-2.5 text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-faint transition-colors hover:bg-v2-background-bg-layer-03 hover:text-v2-text-text-muted focus-visible:bg-v2-background-bg-layer-03 focus-visible:text-v2-text-text-muted focus-visible:outline-none data-[expanded]:bg-v2-background-bg-layer-03 data-[expanded]:text-v2-text-text-muted">
|
||||
<Icon name="branch-out" size="small" class="shrink-0 text-v2-icon-icon-muted" />
|
||||
<span ref={truncation.observe} class="min-w-0 truncate">
|
||||
{language.t("session.new.workspace.fromBranch", { branch: props.branch })}
|
||||
</span>
|
||||
<Icon name="chevron-down" size="small" class="shrink-0 text-v2-icon-icon-muted" />
|
||||
</Menu.Trigger>
|
||||
<Menu.Portal>
|
||||
<Menu.Content
|
||||
class="w-[243px] overflow-hidden rounded-md border-0 bg-v2-background-bg-layer-01 shadow-[var(--v2-elevation-floating)] focus:outline-none"
|
||||
onOpenAutoFocus={(event) => {
|
||||
event.preventDefault()
|
||||
// Kobalte defers its list autofocus until after the focus scope opens.
|
||||
setTimeout(() => requestAnimationFrame(() => input?.focus({ preventScroll: true })))
|
||||
}}
|
||||
>
|
||||
<div class="flex h-7 shrink-0 items-center gap-2 rounded-sm pl-3 pr-1 text-v2-icon-icon-muted">
|
||||
<Icon name="magnifying-glass" size="small" class="shrink-0" />
|
||||
<input
|
||||
ref={input}
|
||||
value={search.branches}
|
||||
placeholder={language.t("session.new.workspace.branch.search.placeholder")}
|
||||
aria-label={language.t("session.new.workspace.branch.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) => {
|
||||
setSearch("branches", event.currentTarget.value)
|
||||
props.onSearch(event.currentTarget.value)
|
||||
}}
|
||||
onKeyDown={(event) => {
|
||||
if (["Escape", "ArrowDown", "ArrowUp", "Enter"].includes(event.key)) return
|
||||
event.stopPropagation()
|
||||
}}
|
||||
/>
|
||||
<Show when={search.branches.trim()}>
|
||||
<button
|
||||
type="button"
|
||||
class="flex size-5 items-center justify-center rounded-sm bg-transparent text-v2-icon-icon-faint transition-colors hover:text-v2-icon-icon-base focus-visible:text-v2-icon-icon-base active:text-v2-icon-icon-base"
|
||||
onPointerDown={(event) => event.preventDefault()}
|
||||
onClick={() => {
|
||||
setSearch("branches", "")
|
||||
props.onSearch("")
|
||||
}}
|
||||
aria-label={language.t("common.clear")}
|
||||
>
|
||||
<Icon name="circle-xmark" />
|
||||
</button>
|
||||
</Show>
|
||||
</div>
|
||||
<div class="max-h-[224px] overflow-y-auto">
|
||||
<Menu.RadioGroup value={props.branch}>
|
||||
<For each={props.branches}>
|
||||
{(branch) => (
|
||||
<Menu.RadioItem
|
||||
value={branch}
|
||||
class="h-7 gap-2 rounded-sm px-3 text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-base [font-family:var(--v2-font-family-sans)] data-[highlighted]:!bg-v2-overlay-simple-overlay-hover"
|
||||
closeOnSelect
|
||||
onSelect={() => (pending = branch)}
|
||||
>
|
||||
<span class="min-w-0 truncate leading-5">{branch}</span>
|
||||
</Menu.RadioItem>
|
||||
)}
|
||||
</For>
|
||||
</Menu.RadioGroup>
|
||||
</div>
|
||||
</Menu.Content>
|
||||
</Menu.Portal>
|
||||
</Menu>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
|
||||
function createTruncatedText() {
|
||||
const [truncated, setTruncated] = createSignal(false)
|
||||
|
||||
return {
|
||||
truncated,
|
||||
observe: (element: HTMLSpanElement) =>
|
||||
createResizeObserver(element, () => setTruncated(element.scrollWidth > element.clientWidth)),
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user