diff --git a/packages/app/src/components/dialog-select-file.tsx b/packages/app/src/components/dialog-select-file.tsx index 64b83d31b..167f21195 100644 --- a/packages/app/src/components/dialog-select-file.tsx +++ b/packages/app/src/components/dialog-select-file.tsx @@ -1,17 +1,22 @@ import { useDialog } from "@opencode-ai/ui/context/dialog" import { Dialog } from "@opencode-ai/ui/dialog" import { FileIcon } from "@opencode-ai/ui/file-icon" +import { Icon } from "@opencode-ai/ui/icon" import { Keybind } from "@opencode-ai/ui/keybind" import { List } from "@opencode-ai/ui/list" +import { base64Encode } from "@opencode-ai/util/encode" import { getDirectory, getFilename } from "@opencode-ai/util/path" -import { useParams } from "@solidjs/router" -import { createMemo, createSignal, onCleanup, Show } from "solid-js" +import { useNavigate, useParams } from "@solidjs/router" +import { createMemo, createSignal, Match, onCleanup, Show, Switch } from "solid-js" import { formatKeybind, useCommand, type CommandOption } from "@/context/command" +import { useGlobalSDK } from "@/context/global-sdk" +import { useGlobalSync } from "@/context/global-sync" import { useLayout } from "@/context/layout" import { useFile } from "@/context/file" import { useLanguage } from "@/context/language" +import { decode64 } from "@/utils/base64" -type EntryType = "command" | "file" +type EntryType = "command" | "file" | "session" type Entry = { id: string @@ -22,6 +27,9 @@ type Entry = { category: string option?: CommandOption path?: string + directory?: string + sessionID?: string + archived?: number } type DialogSelectFileMode = "all" | "files" @@ -33,6 +41,9 @@ export function DialogSelectFile(props: { mode?: DialogSelectFileMode; onOpenFil const file = useFile() const dialog = useDialog() const params = useParams() + const navigate = useNavigate() + const globalSDK = useGlobalSDK() + const globalSync = useGlobalSync() const filesOnly = () => props.mode === "files" const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`) const tabs = createMemo(() => layout.tabs(sessionKey)) @@ -73,6 +84,52 @@ export function DialogSelectFile(props: { mode?: DialogSelectFileMode; onOpenFil path, }) + const projectDirectory = createMemo(() => decode64(params.dir) ?? "") + const project = createMemo(() => { + const directory = projectDirectory() + if (!directory) return + return layout.projects.list().find((p) => p.worktree === directory || p.sandboxes?.includes(directory)) + }) + const workspaces = createMemo(() => { + const directory = projectDirectory() + const current = project() + if (!current) return directory ? [directory] : [] + + const dirs = [current.worktree, ...(current.sandboxes ?? [])] + if (directory && !dirs.includes(directory)) return [...dirs, directory] + return dirs + }) + const homedir = createMemo(() => globalSync.data.path.home) + const label = (directory: string) => { + const current = project() + const kind = + current && directory === current.worktree + ? language.t("workspace.type.local") + : language.t("workspace.type.sandbox") + const [store] = globalSync.child(directory, { bootstrap: false }) + const home = homedir() + const path = home ? directory.replace(home, "~") : directory + const name = store.vcs?.branch ?? getFilename(directory) + return `${kind} : ${name || path}` + } + + const sessionItem = (input: { + directory: string + id: string + title: string + description: string + archived?: number + }): Entry => ({ + id: `session:${input.directory}:${input.id}`, + type: "session", + title: input.title, + description: input.description, + category: language.t("command.category.session"), + directory: input.directory, + sessionID: input.id, + archived: input.archived, + }) + const list = createMemo(() => allowed().map(commandItem)) const picks = createMemo(() => { @@ -122,6 +179,68 @@ export function DialogSelectFile(props: { mode?: DialogSelectFileMode; onOpenFil return out } + const sessionToken = { value: 0 } + let sessionInflight: Promise | undefined + let sessionAll: Entry[] | undefined + + const sessions = (text: string) => { + const query = text.trim() + if (!query) { + sessionToken.value += 1 + sessionInflight = undefined + sessionAll = undefined + return [] as Entry[] + } + + if (sessionAll) return sessionAll + if (sessionInflight) return sessionInflight + + const current = sessionToken.value + const dirs = workspaces() + if (dirs.length === 0) return [] as Entry[] + + sessionInflight = Promise.all( + dirs.map((directory) => { + const description = label(directory) + 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"), + description, + directory, + archived: s.time?.archived, + })), + ) + .catch(() => [] as { id: string; title: string; description: string; directory: string; archived?: number }[]) + }), + ) + .then((results) => { + if (sessionToken.value !== current) return [] as Entry[] + 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 + }) + .map(sessionItem) + sessionAll = next + return next + }) + .catch(() => [] as Entry[]) + .finally(() => { + sessionInflight = undefined + }) + + return sessionInflight + } + const items = async (text: string) => { const query = text.trim() setGrouped(query.length > 0) @@ -146,9 +265,10 @@ export function DialogSelectFile(props: { mode?: DialogSelectFileMode; onOpenFil const files = await file.searchFiles(query) return files.map(fileItem) } - const files = await file.searchFiles(query) + + const [files, nextSessions] = await Promise.all([file.searchFiles(query), Promise.resolve(sessions(query))]) const entries = files.map(fileItem) - return [...list(), ...entries] + return [...list(), ...nextSessions, ...entries] } const handleMove = (item: Entry | undefined) => { @@ -178,6 +298,12 @@ export function DialogSelectFile(props: { mode?: DialogSelectFileMode; onOpenFil return } + if (item.type === "session") { + if (!item.directory || !item.sessionID) return + navigate(`/${base64Encode(item.directory)}/session/${item.sessionID}`) + return + } + if (!item.path) return open(item.path) } @@ -202,13 +328,12 @@ export function DialogSelectFile(props: { mode?: DialogSelectFileMode; onOpenFil items={items} key={(item) => item.id} filterKeys={["title", "description", "category"]} - groupBy={(item) => item.category} + groupBy={grouped() ? (item) => item.category : () => ""} onMove={handleMove} onSelect={handleSelect} > {(item) => ( -
@@ -223,18 +348,43 @@ export function DialogSelectFile(props: { mode?: DialogSelectFileMode; onOpenFil
} > -
-
- {item.title} - - {item.description} + +
+
+ {item.title} + + {item.description} + +
+ + {formatKeybind(item.keybind ?? "")}
- - {formatKeybind(item.keybind ?? "")} - -
- + + +
+
+ +
+ + {item.title} + + + + {item.description} + + +
+
+
+
+ )} diff --git a/packages/app/src/pages/layout.tsx b/packages/app/src/pages/layout.tsx index 2f963ae28..46c9c9154 100644 --- a/packages/app/src/pages/layout.tsx +++ b/packages/app/src/pages/layout.tsx @@ -27,7 +27,6 @@ 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" @@ -2706,14 +2705,6 @@ 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 "" @@ -2729,107 +2720,6 @@ 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 (
-
-
{ - 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} - -
- )} -
-
- -
+
0 && providers.paid().length === 0), + hidden: !(providers.all().length > 0 && providers.paid().length === 0), }} >