diff --git a/packages/app/src/pages/layout.tsx b/packages/app/src/pages/layout.tsx index 5d285c5ec..fe8618b73 100644 --- a/packages/app/src/pages/layout.tsx +++ b/packages/app/src/pages/layout.tsx @@ -27,6 +27,7 @@ import { Button } from "@opencode-ai/ui/button" import { Icon } from "@opencode-ai/ui/icon" import { IconButton } from "@opencode-ai/ui/icon-button" import { InlineInput } from "@opencode-ai/ui/inline-input" +import { List, type ListRef } from "@opencode-ai/ui/list" import { Tooltip, TooltipKeybind } from "@opencode-ai/ui/tooltip" import { HoverCard } from "@opencode-ai/ui/hover-card" import { MessageNav } from "@opencode-ai/ui/message-nav" @@ -2682,6 +2683,14 @@ export default function Layout(props: ParentProps) { } const SidebarPanel = (panelProps: { project: LocalProject | undefined; mobile?: boolean }) => { + type SearchItem = { + id: string + title: string + directory: string + label: string + archived?: number + } + const projectName = createMemo(() => { const project = panelProps.project if (!project) return "" @@ -2697,6 +2706,107 @@ export default function Layout(props: ParentProps) { }) const homedir = createMemo(() => globalSync.data.path.home) + const [search, setSearch] = createStore({ + value: "", + }) + const searching = createMemo(() => search.value.trim().length > 0) + let searchRef: HTMLInputElement | undefined + let listRef: ListRef | undefined + + const token = { value: 0 } + let inflight: Promise | undefined + let all: SearchItem[] | undefined + + const reset = () => { + token.value += 1 + inflight = undefined + all = undefined + setSearch({ value: "" }) + listRef = undefined + } + + const open = (item: SearchItem | undefined) => { + if (!item) return + + const href = `/${base64Encode(item.directory)}/session/${item.id}` + if (!layout.sidebar.opened()) { + setState("hoverSession", undefined) + setState("hoverProject", undefined) + } + reset() + navigate(href) + layout.mobileSidebar.hide() + } + + const items = (filter: string) => { + const query = filter.trim() + if (!query) { + token.value += 1 + inflight = undefined + all = undefined + return [] as SearchItem[] + } + + const project = panelProps.project + if (!project) return [] as SearchItem[] + if (all) return all + if (inflight) return inflight + + const current = token.value + const dirs = workspaceIds(project) + inflight = Promise.all( + dirs.map((input) => { + const directory = workspaceKey(input) + const [workspaceStore] = globalSync.child(directory, { bootstrap: false }) + const kind = + directory === project.worktree ? language.t("workspace.type.local") : language.t("workspace.type.sandbox") + const name = workspaceLabel(directory, workspaceStore.vcs?.branch, project.id) + const label = `${kind} : ${name}` + return globalSDK.client.session + .list({ directory, roots: true }) + .then((x) => + (x.data ?? []) + .filter((s) => !!s?.id) + .map((s) => ({ + id: s.id, + title: s.title ?? language.t("command.session.new"), + directory, + label, + archived: s.time?.archived, + })), + ) + .catch(() => [] as SearchItem[]) + }), + ) + .then((results) => { + if (token.value !== current) return [] as SearchItem[] + + const seen = new Set() + const next = results.flat().filter((item) => { + const key = `${item.directory}:${item.id}` + if (seen.has(key)) return false + seen.add(key) + return true + }) + all = next + return next + }) + .catch(() => [] as SearchItem[]) + .finally(() => { + inflight = undefined + }) + + return inflight + } + + createEffect( + on( + () => panelProps.project?.worktree, + () => reset(), + { defer: true }, + ), + ) + return (
- + {(p) => ( <>
@@ -2714,7 +2824,7 @@ export default function Layout(props: ParentProps) { renameProject(p, next)} + onSave={(next) => renameProject(p(), next)} class="text-16-medium text-text-strong truncate" displayClass="text-16-medium text-text-strong truncate" stopPropagation @@ -2723,7 +2833,7 @@ export default function Layout(props: ParentProps) { - {p.worktree.replace(homedir(), "~")} + {p().worktree.replace(homedir(), "~")}
@@ -2742,31 +2852,31 @@ export default function Layout(props: ParentProps) { icon="dot-grid" variant="ghost" data-action="project-menu" - data-project={base64Encode(p.worktree)} + data-project={base64Encode(p().worktree)} class="shrink-0 size-6 rounded-md opacity-0 group-hover/project:opacity-100 data-[expanded]:opacity-100 data-[expanded]:bg-surface-base-active" aria-label={language.t("common.moreOptions")} /> - dialog.show(() => )}> + dialog.show(() => )}> {language.t("common.edit")} { - const enabled = layout.sidebar.workspaces(p.worktree)() + const enabled = layout.sidebar.workspaces(p().worktree)() if (enabled) { - layout.sidebar.toggleWorkspaces(p.worktree) + layout.sidebar.toggleWorkspaces(p().worktree) return } - if (p.vcs !== "git") return - layout.sidebar.toggleWorkspaces(p.worktree) + if (p().vcs !== "git") return + layout.sidebar.toggleWorkspaces(p().worktree) }} > - {layout.sidebar.workspaces(p.worktree)() + {layout.sidebar.workspaces(p().worktree)() ? language.t("sidebar.workspaces.disable") : language.t("sidebar.workspaces.enable")} @@ -2774,8 +2884,8 @@ export default function Layout(props: ParentProps) { closeProject(p.worktree)} + data-project={base64Encode(p().worktree)} + onSelect={() => closeProject(p().worktree)} > {language.t("common.close")} @@ -2785,103 +2895,207 @@ export default function Layout(props: ParentProps) {
- +
{ + const target = event.target + if (!(target instanceof Element)) return + if (target.closest("input, textarea, [contenteditable='true']")) return + searchRef?.focus() + }} + > + + { + searchRef = el + }} + class="flex-1 min-w-0 text-14-regular text-text-strong placeholder:text-text-weak" + style={{ "box-shadow": "none" }} + value={search.value} + onInput={(event) => setSearch("value", event.currentTarget.value)} + onKeyDown={(event) => { + if (event.key === "Escape") { + event.preventDefault() + setSearch("value", "") + queueMicrotask(() => searchRef?.focus()) + return + } + + if (!searching()) return + + if (event.key === "ArrowDown" || event.key === "ArrowUp" || event.key === "Enter") { + const ref = listRef + if (!ref) return + event.stopPropagation() + ref.onKeyDown(event) + return + } + + if (event.ctrlKey && !event.metaKey && !event.altKey && !event.shiftKey) { + if (event.key === "n" || event.key === "p") { + const ref = listRef + if (!ref) return + event.stopPropagation() + ref.onKeyDown(event) + } + } + }} + placeholder={language.t("session.header.search.placeholder", { project: projectName() })} + spellcheck={false} + autocorrect="off" + autocomplete="off" + autocapitalize="off" + /> + + { + setSearch("value", "") + queueMicrotask(() => searchRef?.focus()) + }} + /> + +
+ + + + `${item.directory}:${item.id}`} + onSelect={open} + ref={(ref) => { + listRef = ref + }} + > + {(item) => ( +
+ + {item.title} + + + {item.label} + +
+ )} +
+
+ +
+ +
+ + + +
+
+ +
+ + } + > <> -
+
-
-
- +
+ + + +
{ + if (!panelProps.mobile) scrollContainerRef = el + }} + class="size-full flex flex-col py-2 gap-4 overflow-y-auto no-scrollbar [overflow-anchor:none]" + > + + + {(directory) => ( + + )} + + +
+ + + +
- } - > - <> -
- - - -
-
- - - -
{ - if (!panelProps.mobile) scrollContainerRef = el - }} - class="size-full flex flex-col py-2 gap-4 overflow-y-auto no-scrollbar [overflow-anchor:none]" - > - - - {(directory) => ( - - )} - - -
- - - -
-
- - + +
)} - 0 && providers.paid().length === 0}> -
-
-
-
{language.t("sidebar.gettingStarted.title")}
-
{language.t("sidebar.gettingStarted.line1")}
-
{language.t("sidebar.gettingStarted.line2")}
-
- + +
0 && providers.paid().length === 0), + }} + > +
+
+
{language.t("sidebar.gettingStarted.title")}
+
{language.t("sidebar.gettingStarted.line1")}
+
{language.t("sidebar.gettingStarted.line2")}
+
- +
) } diff --git a/packages/ui/src/components/list.tsx b/packages/ui/src/components/list.tsx index 6c654cbb7..abd557220 100644 --- a/packages/ui/src/components/list.tsx +++ b/packages/ui/src/components/list.tsx @@ -57,6 +57,7 @@ export function List(props: ListProps & { ref?: (ref: ListRef) => void }) const i18n = useI18n() const [scrollRef, setScrollRef] = createSignal(undefined) const [internalFilter, setInternalFilter] = createSignal("") + let inputRef: HTMLInputElement | HTMLTextAreaElement | undefined const [store, setStore] = createStore({ mouseActive: false, }) @@ -176,6 +177,14 @@ export function List(props: ListProps & { ref?: (ref: ListRef) => void }) if (e.key === "Enter" && !e.isComposing) { e.preventDefault() if (selected) handleSelect(selected, index) + } else if (props.search) { + if (e.ctrlKey && !e.metaKey && !e.altKey && !e.shiftKey && (e.key === "n" || e.key === "p")) { + onKeyDown(e) + return + } + if (e.key === "ArrowDown" || e.key === "ArrowUp") { + onKeyDown(e) + } } else { onKeyDown(e) } @@ -247,7 +256,21 @@ export function List(props: ListProps & { ref?: (ref: ListRef) => void })
-
+
{ + const container = event.currentTarget + if (!(container instanceof HTMLElement)) return + + const node = container.querySelector("input, textarea") + const input = node instanceof HTMLInputElement || node instanceof HTMLTextAreaElement ? node : inputRef + input?.focus() + + // Prevent global listeners (e.g. dnd sensors) from cancelling focus. + event.stopPropagation() + }} + >
@@ -257,6 +280,9 @@ export function List(props: ListProps & { ref?: (ref: ListRef) => void }) variant="ghost" data-slot="list-search-input" type="text" + ref={(el: HTMLInputElement | HTMLTextAreaElement) => { + inputRef = el + }} value={internalFilter()} onChange={(value) => applyFilter(value)} onKeyDown={handleKey} @@ -271,7 +297,10 @@ export function List(props: ListProps & { ref?: (ref: ListRef) => void }) applyFilter("")} + onClick={() => { + setInternalFilter("") + queueMicrotask(() => inputRef?.focus()) + }} aria-label={i18n.t("ui.list.clearFilter")} />