import { createEffect, createSignal, For, onCleanup, Show, splitProps, type Accessor, type ComponentProps, } from "solid-js" import { createStore } from "solid-js/store" import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu" import { Icon } from "@opencode-ai/ui/icon" import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon" import { ProjectAvatar } from "@opencode-ai/ui/v2/project-avatar-v2" import { getProjectAvatarVariant } from "@/context/layout" import { useLanguage } from "@/context/language" import { displayName, getProjectAvatarSource } from "@/pages/layout/helpers" import { pathKey } from "@/utils/path-key" import { handleDocumentSearchKeydown } from "@/utils/search-keydown" export type PromptProject = { name?: string id?: string worktree: string sandboxes?: string[] icon?: { color?: string; url?: string; override?: string } server?: { key: string; name: string } } export type PromptProjectControls = { available: PromptProject[] directory: string server?: string select: (worktree: string, server?: string) => void add: (title: string, server?: string) => void } const actionPrefix = "action:" const projectPrefix = "project:" function projectKey(project: PromptProject) { return `${projectPrefix}${encodeURIComponent(project.server?.key ?? "")}:${encodeURIComponent(project.worktree)}` } function actionKey(server?: string) { return `${actionPrefix}${encodeURIComponent(server ?? "")}` } export function createPromptProjectController(input: { controls: Accessor onDone: () => void }) { const language = useLanguage() const [store, setStore] = createStore({ open: false, search: "", active: "" }) let searchRef: HTMLInputElement | undefined const selected = () => { const key = pathKey(input.controls().directory) return input .controls() .available.find( (project) => (!project.server || project.server.key === input.controls().server) && (pathKey(project.worktree) === key || project.sandboxes?.some((sandbox) => pathKey(sandbox) === key)), ) } const projects = () => { const search = store.search.trim().toLowerCase() if (!search) return input.controls().available return input.controls().available.filter((project) => displayName(project).toLowerCase().includes(search)) } const servers = () => input .controls() .available.map((project) => project.server) .filter((server, index, all) => server && all.findIndex((item) => item?.key === server.key) === index) const keys = () => { if (servers().length <= 1) { return [...projects().map(projectKey), actionKey(servers()[0]?.key)] } return [ ...servers().flatMap((server) => projects() .filter((project) => project.server?.key === server!.key) .map(projectKey), ), actionKey(), ] } const initialActive = () => { const selectedKey = selected() ? projectKey(selected()!) : undefined const options = keys() if (selectedKey && options.includes(selectedKey)) return selectedKey return options[0] ?? "" } const close = () => { setStore({ open: false, search: "", active: "" }) input.onDone() } const select = (project: PromptProject) => { if ( pathKey(project.worktree) !== pathKey(selected()?.worktree ?? "") || project.server?.key !== selected()?.server?.key ) { input.controls().select(project.worktree, project.server?.key) } close() } const add = (server?: string) => { setStore({ open: false, search: "", active: "" }) input.controls().add(language.t("command.project.open"), server) } const setSearch = (value: string) => { const search = value.trim().toLowerCase() const first = input .controls() .available.find((project) => !search || displayName(project).toLowerCase().includes(search)) setStore({ search: value, active: first ? projectKey(first) : actionKey(servers().length > 1 ? undefined : servers()[0]?.key), }) } return { selected, projects, servers, projectKey, actionKey, open: () => store.open, search: () => store.search, active: () => store.active, labels: { add: () => language.t("session.new.project.add"), clear: () => language.t("common.clear"), new: () => language.t("session.new.project.new"), search: () => language.t("session.new.project.search"), }, add, select, setOpen(open: boolean) { if (open) { setStore({ open: true, active: initialActive() }) setTimeout(() => requestAnimationFrame(() => searchRef?.focus())) return } setStore({ open: false, search: "", active: "" }) }, setSearch, clearSearch() { setStore({ search: "", active: initialActive() }) setTimeout(() => searchRef?.focus()) }, setActive(key: string) { setStore("active", key) }, moveActive(delta: number) { const options = keys() if (options.length === 0) return const index = options.indexOf(store.active) const start = index === -1 ? 0 : index setStore("active", options[(start + delta + options.length) % options.length]) }, activeProject() { return store.active.startsWith(projectPrefix) ? projects().find((project) => projectKey(project) === store.active) : undefined }, activeServer() { return store.active.startsWith(actionPrefix) ? decodeURIComponent(store.active.slice(actionPrefix.length)) || undefined : undefined }, activeAction() { return store.active.startsWith(actionPrefix) }, setSearchRef(el: HTMLInputElement) { searchRef = el }, focusSearch() { setTimeout(() => requestAnimationFrame(() => searchRef?.focus())) }, handleSearchKeydown(event: KeyboardEvent) { return handleDocumentSearchKeydown(searchRef, event, store.search, setSearch) }, } } export type PromptProjectController = ReturnType export function PromptProjectSelector(props: { controller: PromptProjectController placement?: "bottom" | "bottom-start" }) { const [triggerReady, setTriggerReady] = createSignal(false) let contentRef: HTMLDivElement | undefined let triggerFrame: number | undefined let restoreTrigger = true // Floating UI requires a connected anchor; route transitions can construct this trigger before adoption. const setTriggerRef = (element: HTMLButtonElement) => { const ready = () => { if (!element.isConnected) { triggerFrame = requestAnimationFrame(ready) return } triggerFrame = undefined setTriggerReady(true) } ready() } onCleanup(() => { if (triggerFrame !== undefined) cancelAnimationFrame(triggerFrame) }) const activeItem = () => props.controller.active() ? contentRef?.querySelector(`[data-option-key="${CSS.escape(props.controller.active())}"]`) : undefined const afterClose = (callback: () => void) => { const complete = () => { if (contentRef?.isConnected) { requestAnimationFrame(complete) return } requestAnimationFrame(() => requestAnimationFrame(callback)) } requestAnimationFrame(complete) } const selectProject = (project: PromptProject) => { restoreTrigger = false props.controller.setOpen(false) afterClose(() => props.controller.select(project)) } const selectAction = (server?: string) => { restoreTrigger = false props.controller.setOpen(false) afterClose(() => props.controller.add(server)) } const selectActive = () => { const project = props.controller.activeProject() if (project) { selectProject(project) return } if (props.controller.activeAction() && props.controller.servers().length > 1) { const item = activeItem() item?.focus() item?.dispatchEvent(new KeyboardEvent("keydown", { key: "ArrowRight", bubbles: true })) return } selectAction(props.controller.activeServer()) } const moveActive = (delta: number) => { props.controller.moveActive(delta) queueMicrotask(() => activeItem()?.scrollIntoView({ block: "nearest" })) } const focusPreviousControl = () => { const target = Array.from( document.querySelectorAll( 'button:not([disabled]), a[href], input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])', ), ) .filter((element) => !contentRef?.contains(element) && !element.hasAttribute("data-focus-trap")) .findLast((element) => element.offsetParent !== null) restoreTrigger = false target?.focus() queueMicrotask(() => { if (props.controller.open()) props.controller.setOpen(false) }) } const selectedValue = () => { const project = props.controller.selected() return project ? props.controller.projectKey(project) : undefined } createEffect(() => { if (!props.controller.open()) return const handler = (event: KeyboardEvent) => props.controller.handleSearchKeydown(event) document.addEventListener("keydown", handler, true) onCleanup(() => document.removeEventListener("keydown", handler, true)) }) return ( props.controller.setOpen(open)} > event.preventDefault()} onPointerDownOutside={() => (restoreTrigger = false)} onFocusOutside={() => (restoreTrigger = false)} onCloseAutoFocus={(event) => { if (!restoreTrigger) event.preventDefault() }} >
props.controller.setSearchRef(el)} value={props.controller.search()} placeholder={props.controller.labels.search()} aria-autocomplete="list" aria-controls="prompt-project-menu" aria-activedescendant={props.controller.active() || undefined} 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) => props.controller.setSearch(event.currentTarget.value)} onKeyDown={(event) => { if (event.key === "Tab") { event.preventDefault() event.stopPropagation() if (event.shiftKey) { focusPreviousControl() return } activeItem()?.focus() return } event.stopPropagation() if (event.key === "Escape") { event.preventDefault() props.controller.setOpen(false) return } if (event.altKey || event.metaKey) return if (event.key === "ArrowDown") { event.preventDefault() moveActive(1) return } if (event.key === "ArrowUp") { event.preventDefault() moveActive(-1) return } if (event.key === "Enter" && !event.isComposing) { event.preventDefault() selectActive() } }} />
1} fallback={ {(project) => ( )} } > props.controller.projects().some((project) => project.server?.key === server!.key), )} > {(server) => (
{server!.name}
project.server?.key === server!.key)}> {(project) => ( )}
)}
1} fallback={ } > props.controller.setActive(props.controller.actionKey())} > {props.controller.labels.add()} {(server) => }
) } export function PromptProjectAddButton(props: { controller: PromptProjectController }) { return ( ) } function ProjectTrigger(props: ComponentProps<"button"> & { controller: PromptProjectController }) { const [local, rest] = splitProps(props, ["controller", "class", "classList", "onClick", "onKeyDown"]) const project = () => local.controller.selected() return ( ) } function ProjectItem(props: { project: PromptProject controller: PromptProjectController onSelect: (project: PromptProject) => void }) { const key = () => props.controller.projectKey(props.project) return ( { props.controller.setActive(key()) props.controller.focusSearch() }} onSelect={() => props.onSelect(props.project)} > {displayName(props.project)} ) } const projectActionClass = "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" function ProjectAction(props: { server?: string controller: PromptProjectController onSelect: (server?: string) => void }) { const key = () => props.controller.actionKey(props.server) return ( { props.controller.setActive(key()) props.controller.focusSearch() }} onSelect={() => props.onSelect(props.server)} > {props.controller.labels.add()} ) } function ServerAction(props: { server: { key: string; name: string }; onSelect: (server: string) => void }) { return ( props.onSelect(props.server.key)}> {props.server.name} ) }