diff options
Diffstat (limited to 'packages/desktop/src/pages')
| -rw-r--r-- | packages/desktop/src/pages/index.tsx | 857 | ||||
| -rw-r--r-- | packages/desktop/src/pages/layout.tsx | 75 | ||||
| -rw-r--r-- | packages/desktop/src/pages/session-layout.tsx | 12 | ||||
| -rw-r--r-- | packages/desktop/src/pages/session.tsx | 693 |
4 files changed, 780 insertions, 857 deletions
diff --git a/packages/desktop/src/pages/index.tsx b/packages/desktop/src/pages/index.tsx deleted file mode 100644 index 5f04c3dbe..000000000 --- a/packages/desktop/src/pages/index.tsx +++ /dev/null @@ -1,857 +0,0 @@ -import { - Button, - List, - SelectDialog, - Tooltip, - IconButton, - Tabs, - Icon, - Accordion, - Diff, - Collapsible, - DiffChanges, - Message, - Typewriter, - Card, - Code, -} from "@opencode-ai/ui" -import { FileIcon } from "@/ui" -import FileTree from "@/components/file-tree" -import { MessageProgress } from "@/components/message-progress" -import { For, onCleanup, onMount, Show, Match, Switch, createSignal, createEffect, createMemo } from "solid-js" -import { useLocal, type LocalFile } from "@/context/local" -import { createStore } from "solid-js/store" -import { getDirectory, getFilename } from "@/utils" -import { ContentPart, PromptInput } from "@/components/prompt-input" -import { DateTime } from "luxon" -import { - DragDropProvider, - DragDropSensors, - DragOverlay, - SortableProvider, - closestCenter, - createSortable, - useDragDropContext, -} from "@thisbeyond/solid-dnd" -import type { DragEvent, Transformer } from "@thisbeyond/solid-dnd" -import type { JSX } from "solid-js" -import { useSync } from "@/context/sync" -import { useSDK } from "@/context/sdk" -import { type AssistantMessage as AssistantMessageType } from "@opencode-ai/sdk" -import { Markdown } from "@opencode-ai/ui" -import { Spinner } from "@/components/spinner" - -export default function Page() { - const local = useLocal() - const sync = useSync() - const sdk = useSDK() - const [store, setStore] = createStore({ - clickTimer: undefined as number | undefined, - fileSelectOpen: false, - }) - let inputRef!: HTMLDivElement - let messageScrollElement!: HTMLDivElement - const [activeItem, setActiveItem] = createSignal<string | undefined>(undefined) - - const MOD = typeof navigator === "object" && /(Mac|iPod|iPhone|iPad)/.test(navigator.platform) ? "Meta" : "Control" - - onMount(() => { - document.addEventListener("keydown", handleKeyDown) - }) - - onCleanup(() => { - document.removeEventListener("keydown", handleKeyDown) - }) - - const handleKeyDown = (event: KeyboardEvent) => { - if (event.getModifierState(MOD) && event.shiftKey && event.key.toLowerCase() === "p") { - event.preventDefault() - return - } - if (event.getModifierState(MOD) && event.key.toLowerCase() === "p") { - event.preventDefault() - setStore("fileSelectOpen", true) - return - } - - const focused = document.activeElement === inputRef - if (focused) { - if (event.key === "Escape") { - inputRef?.blur() - } - return - } - - // if (local.file.active()) { - // const active = local.file.active()! - // if (event.key === "Enter" && active.selection) { - // local.context.add({ - // type: "file", - // path: active.path, - // selection: { ...active.selection }, - // }) - // return - // } - // - // if (event.getModifierState(MOD)) { - // if (event.key.toLowerCase() === "a") { - // return - // } - // if (event.key.toLowerCase() === "c") { - // return - // } - // } - // } - - if (event.key.length === 1 && event.key !== "Unidentified" && !(event.ctrlKey || event.metaKey)) { - inputRef?.focus() - } - } - - const resetClickTimer = () => { - if (!store.clickTimer) return - clearTimeout(store.clickTimer) - setStore("clickTimer", undefined) - } - - const startClickTimer = () => { - const newClickTimer = setTimeout(() => { - setStore("clickTimer", undefined) - }, 300) - setStore("clickTimer", newClickTimer as unknown as number) - } - - const handleFileClick = async (file: LocalFile) => { - if (store.clickTimer) { - resetClickTimer() - local.file.update(file.path, { ...file, pinned: true }) - } else { - local.file.open(file.path) - startClickTimer() - } - } - - // const navigateChange = (dir: 1 | -1) => { - // const active = local.file.active() - // if (!active) return - // const current = local.file.changeIndex(active.path) - // const next = current === undefined ? (dir === 1 ? 0 : -1) : current + dir - // local.file.setChangeIndex(active.path, next) - // } - - const handleTabChange = (path: string) => { - local.session.setActiveTab(path) - if (path === "chat") return - local.session.open(path) - } - - const handleTabClose = (file: LocalFile) => { - local.session.close(file.path) - } - - const handleDragStart = (event: unknown) => { - const id = getDraggableId(event) - if (!id) return - setActiveItem(id) - } - - const handleDragOver = (event: DragEvent) => { - const { draggable, droppable } = event - if (draggable && droppable) { - const currentFiles = local.session.tabs()?.opened.map((file) => file) - const fromIndex = currentFiles?.indexOf(draggable.id.toString()) - const toIndex = currentFiles?.indexOf(droppable.id.toString()) - if (fromIndex !== toIndex && toIndex !== undefined) { - local.session.move(draggable.id.toString(), toIndex) - } - } - } - - const handleDragEnd = () => { - setActiveItem(undefined) - } - - // const scrollDiffItem = (element: HTMLElement) => { - // element.scrollIntoView({ block: "start", behavior: "instant" }) - // } - - const handleDiffTriggerClick = (event: MouseEvent) => { - // disabling scroll to diff for now - return - // const target = event.currentTarget as HTMLElement - // queueMicrotask(() => { - // if (target.getAttribute("aria-expanded") !== "true") return - // const item = target.closest('[data-slot="accordion-item"]') as HTMLElement | null - // if (!item) return - // scrollDiffItem(item) - // }) - } - - const handlePromptSubmit = async (parts: ContentPart[]) => { - const existingSession = local.session.active() - let session = existingSession - if (!session) { - const created = await sdk.client.session.create() - session = created.data ?? undefined - } - if (!session) return - - local.session.setActive(session.id) - if (!existingSession) { - local.session.copyTabs("", session.id) - } - local.session.setActiveTab(undefined) - const toAbsolutePath = (path: string) => (path.startsWith("/") ? path : sync.absolute(path)) - - const text = parts.map((part) => part.content).join("") - const attachments = parts.filter((part) => part.type === "file") - - // const activeFile = local.context.active() - // if (activeFile) { - // registerAttachment( - // activeFile.path, - // activeFile.selection, - // activeFile.name ?? formatAttachmentLabel(activeFile.path, activeFile.selection), - // ) - // } - - // for (const contextFile of local.context.all()) { - // registerAttachment( - // contextFile.path, - // contextFile.selection, - // formatAttachmentLabel(contextFile.path, contextFile.selection), - // ) - // } - - const attachmentParts = attachments.map((attachment) => { - const absolute = toAbsolutePath(attachment.path) - const query = attachment.selection - ? `?start=${attachment.selection.startLine}&end=${attachment.selection.endLine}` - : "" - return { - type: "file" as const, - mime: "text/plain", - url: `file://${absolute}${query}`, - filename: getFilename(attachment.path), - source: { - type: "file" as const, - text: { - value: attachment.content, - start: attachment.start, - end: attachment.end, - }, - path: absolute, - }, - } - }) - - await sdk.client.session.prompt({ - path: { id: session.id }, - body: { - agent: local.agent.current()!.name, - model: { - modelID: local.model.current()!.id, - providerID: local.model.current()!.provider.id, - }, - parts: [ - { - type: "text", - text, - }, - ...attachmentParts, - ], - }, - }) - } - - const handleNewSession = () => { - local.session.setActive(undefined) - inputRef?.focus() - } - - const TabVisual = (props: { file: LocalFile }): JSX.Element => { - return ( - <div class="flex items-center gap-x-1.5"> - <FileIcon node={props.file} class="grayscale-100 group-data-[selected]/tab:grayscale-0" /> - <span - classList={{ - "text-14-medium": true, - "text-primary": !!props.file.status?.status, - italic: !props.file.pinned, - }} - > - {props.file.name} - </span> - <span class="hidden opacity-70"> - <Switch> - <Match when={props.file.status?.status === "modified"}> - <span class="text-primary">M</span> - </Match> - <Match when={props.file.status?.status === "added"}> - <span class="text-success">A</span> - </Match> - <Match when={props.file.status?.status === "deleted"}> - <span class="text-error">D</span> - </Match> - </Switch> - </span> - </div> - ) - } - - const SortableTab = (props: { - file: LocalFile - onTabClick: (file: LocalFile) => void - onTabClose: (file: LocalFile) => void - }): JSX.Element => { - const sortable = createSortable(props.file.path) - - return ( - // @ts-ignore - <div use:sortable classList={{ "h-full": true, "opacity-0": sortable.isActiveDraggable }}> - <Tooltip value={props.file.path} placement="bottom" class="h-full"> - <div class="relative h-full"> - <Tabs.Trigger - value={props.file.path} - class="group/tab pl-3 pr-1" - onClick={() => props.onTabClick(props.file)} - > - <TabVisual file={props.file} /> - <IconButton - icon="close" - class="mt-0.5 opacity-0 text-text-muted/60 group-data-[selected]/tab:opacity-100 - group-data-[selected]/tab:text-text group-data-[selected]/tab:hover:bg-border-subtle - hover:opacity-100 group-hover/tab:opacity-100" - variant="ghost" - onClick={() => props.onTabClose(props.file)} - /> - </Tabs.Trigger> - </div> - </Tooltip> - </div> - ) - } - - const ConstrainDragYAxis = (): JSX.Element => { - const context = useDragDropContext() - if (!context) return <></> - const [, { onDragStart, onDragEnd, addTransformer, removeTransformer }] = context - const transformer: Transformer = { - id: "constrain-y-axis", - order: 100, - callback: (transform) => ({ ...transform, y: 0 }), - } - onDragStart((event) => { - const id = getDraggableId(event) - if (!id) return - addTransformer("draggables", id, transformer) - }) - onDragEnd((event) => { - const id = getDraggableId(event) - if (!id) return - removeTransformer("draggables", id, transformer.id) - }) - return <></> - } - - const getDraggableId = (event: unknown): string | undefined => { - if (typeof event !== "object" || event === null) return undefined - if (!("draggable" in event)) return undefined - const draggable = (event as { draggable?: { id?: unknown } }).draggable - if (!draggable) return undefined - return typeof draggable.id === "string" ? draggable.id : undefined - } - - return ( - <div class="relative h-screen flex flex-col"> - <header class="hidden h-12 shrink-0 bg-background-strong border-b border-border-weak-base"></header> - <main class="h-[calc(100vh-0rem)] flex"> - <div class="w-70 shrink-0 bg-background-weak border-r border-border-weak-base flex flex-col items-start"> - <div class="h-10 flex items-center self-stretch px-5 border-b border-border-weak-base"> - <span class="text-14-regular overflow-hidden text-ellipsis">{getFilename(sync.data.path.directory)}</span> - </div> - <div class="flex flex-col items-start gap-4 self-stretch flex-1 py-4 px-3"> - <Button class="w-full" size="large" onClick={handleNewSession} icon="edit-small-2"> - New Session - </Button> - <List - data={sync.data.session} - key={(x) => x.id} - current={local.session.active()} - onSelect={(s) => local.session.setActive(s?.id)} - onHover={(s) => (!!s ? sync.session.sync(s?.id) : undefined)} - > - {(session) => { - const diffs = createMemo(() => session.summary?.diffs ?? []) - const filesChanged = createMemo(() => diffs().length) - const updated = DateTime.fromMillis(session.time.updated) - return ( - <Tooltip placement="right" value={session.title}> - <div> - <div class="flex items-center self-stretch gap-6 justify-between"> - <span class="text-14-regular text-text-strong overflow-hidden text-ellipsis truncate"> - {session.title} - </span> - <span class="text-12-regular text-text-weak text-right whitespace-nowrap"> - {Math.abs(updated.diffNow().as("seconds")) < 60 - ? "Now" - : updated - .toRelative({ style: "short", unit: ["days", "hours", "minutes"] }) - ?.replace(" ago", "") - ?.replace(/ days?/, "d") - ?.replace(" min.", "m") - ?.replace(" hr.", "h")} - </span> - </div> - <div class="flex justify-between items-center self-stretch"> - <span class="text-12-regular text-text-weak">{`${filesChanged() || "No"} file${filesChanged() !== 1 ? "s" : ""} changed`}</span> - <DiffChanges diff={diffs()} /> - </div> - </div> - </Tooltip> - ) - }} - </List> - </div> - </div> - <div class="relative bg-background-base w-full h-full overflow-x-hidden"> - <DragDropProvider - onDragStart={handleDragStart} - onDragEnd={handleDragEnd} - onDragOver={handleDragOver} - collisionDetector={closestCenter} - > - <DragDropSensors /> - <ConstrainDragYAxis /> - <Tabs value={local.session.tabs()?.active ?? "chat"} onChange={handleTabChange}> - <div class="sticky top-0 shrink-0 flex"> - <Tabs.List> - <Tabs.Trigger value="chat" class="flex gap-x-4 items-center"> - <div>Chat</div> - {/* <Tooltip value={`${local.session.tokens() ?? 0} Tokens`} class="flex items-center gap-1.5"> */} - {/* <ProgressCircle percentage={local.session.context() ?? 0} /> */} - {/* <div class="text-14-regular text-text-weak text-right">{local.session.context() ?? 0}%</div> */} - {/* </Tooltip> */} - </Tabs.Trigger> - {/* <Tabs.Trigger value="review">Review</Tabs.Trigger> */} - <SortableProvider ids={local.session.tabs()?.opened ?? []}> - <For each={local.session.tabs()?.opened ?? []}> - {(file) => ( - <SortableTab - file={local.file.node(file)} - onTabClick={handleFileClick} - onTabClose={handleTabClose} - /> - )} - </For> - </SortableProvider> - <div class="bg-background-base h-full flex items-center justify-center border-b border-border-weak-base px-3"> - <IconButton - icon="plus-small" - variant="ghost" - iconSize="large" - onClick={() => setStore("fileSelectOpen", true)} - /> - </div> - </Tabs.List> - </div> - <Tabs.Content value="chat" class="@container select-text flex flex-col flex-1 min-h-0 overflow-y-hidden"> - <div class="relative px-6 pt-12 max-w-2xl w-full mx-auto flex flex-col flex-1 min-h-0"> - <Show - when={local.session.active()} - fallback={ - <div class="flex flex-col pb-45 justify-end items-start gap-4 flex-[1_0_0] self-stretch"> - <div class="text-20-medium text-text-weaker">New session</div> - <div class="flex justify-center items-center gap-3"> - <Icon name="folder" size="small" /> - <div class="text-12-medium text-text-weak"> - {getDirectory(sync.data.path.directory)} - <span class="text-text-strong">{getFilename(sync.data.path.directory)}</span> - </div> - </div> - <div class="flex justify-center items-center gap-3"> - <Icon name="pencil-line" size="small" /> - <div class="text-12-medium text-text-weak"> - Last modified - <span class="text-text-strong"> - {DateTime.fromMillis(sync.data.project.time.created).toRelative()} - </span> - </div> - </div> - </div> - } - > - {(session) => { - const [store, setStore] = createStore<{ - messageId?: string - }>() - - const messages = createMemo(() => sync.data.message[session().id] ?? []) - const userMessages = createMemo(() => - messages() - .filter((m) => m.role === "user") - .sort((a, b) => b.id.localeCompare(a.id)), - ) - const lastUserMessage = createMemo(() => { - return userMessages()?.at(0) - }) - const activeMessage = createMemo(() => { - if (!store.messageId) return lastUserMessage() - return userMessages()?.find((m) => m.id === store.messageId) - }) - - return ( - <div class="pt-3 flex flex-col flex-1 min-h-0"> - <div class="flex-1 min-h-0"> - <Show when={userMessages().length > 1}> - <ul - role="list" - class="absolute right-full mr-8 hidden w-60 shrink-0 @7xl:flex flex-col items-start gap-1" - > - <For each={userMessages()}> - {(message) => { - const diffs = createMemo(() => message.summary?.diffs ?? []) - const assistantMessages = createMemo(() => { - return sync.data.message[session().id]?.filter( - (m) => m.role === "assistant" && m.parentID == message.id, - ) as AssistantMessageType[] - }) - const error = createMemo(() => assistantMessages().find((m) => m?.error)?.error) - const working = createMemo(() => !message.summary?.body && !error()) - - return ( - <li class="group/li flex items-center self-stretch"> - <button - class="flex items-center self-stretch w-full gap-x-2 py-1 cursor-default" - onClick={() => setStore("messageId", message.id)} - > - <Switch> - <Match when={working()}> - <Spinner class="text-text-base shrink-0 w-[18px] aspect-square" /> - </Match> - <Match when={true}> - <DiffChanges diff={diffs()} variant="bars" /> - </Match> - </Switch> - <div - data-active={activeMessage()?.id === message.id} - classList={{ - "text-14-regular text-text-weak whitespace-nowrap truncate min-w-0": true, - "text-text-weak data-[active=true]:text-text-strong group-hover/li:text-text-base": true, - }} - > - <Show when={message.summary?.title} fallback="New message"> - {message.summary?.title} - </Show> - </div> - </button> - </li> - ) - }} - </For> - </ul> - </Show> - <div ref={messageScrollElement} class="grow min-w-0 h-full overflow-y-auto no-scrollbar"> - <For each={userMessages()}> - {(message) => { - const isActive = createMemo(() => activeMessage()?.id === message.id) - const [titled, setTitled] = createSignal(!!message.summary?.title) - const assistantMessages = createMemo(() => { - return sync.data.message[session().id]?.filter( - (m) => m.role === "assistant" && m.parentID == message.id, - ) as AssistantMessageType[] - }) - const error = createMemo(() => assistantMessages().find((m) => m?.error)?.error) - const [completed, setCompleted] = createSignal(!!message.summary?.body || !!error()) - const [expanded, setExpanded] = createSignal(false) - const parts = createMemo(() => sync.data.part[message.id]) - const title = createMemo(() => message.summary?.title) - const summary = createMemo(() => message.summary?.body) - const diffs = createMemo(() => message.summary?.diffs ?? []) - const hasToolPart = createMemo(() => - assistantMessages() - ?.flatMap((m) => sync.data.part[m.id]) - .some((p) => p?.type === "tool"), - ) - const working = createMemo(() => !summary() && !error()) - - // allowing time for the animations to finish - createEffect(() => { - title() - setTimeout(() => setTitled(!!title()), 10_000) - }) - createEffect(() => { - const complete = !!summary() || !!error() - setTimeout(() => setCompleted(complete), 1200) - }) - - return ( - <Show when={isActive()}> - <div - data-message={message.id} - class="flex flex-col items-start self-stretch gap-8 pb-50" - > - {/* Title */} - <div class="py-2 flex flex-col items-start gap-2 self-stretch sticky top-0 bg-background-stronger z-10"> - <div class="w-full text-14-medium text-text-strong"> - <Show - when={titled()} - fallback={ - <Typewriter - as="h1" - text={title()} - class="overflow-hidden text-ellipsis min-w-0 text-nowrap" - /> - } - > - <h1 class="overflow-hidden text-ellipsis min-w-0 text-nowrap"> - {title()} - </h1> - </Show> - </div> - </div> - <div class="-mt-8"> - <Message message={message} parts={parts()} /> - </div> - {/* Summary */} - <Show when={completed()}> - <div class="w-full flex flex-col gap-6 items-start self-stretch"> - <div class="flex flex-col items-start gap-1 self-stretch"> - <h2 class="text-12-medium text-text-weak"> - <Switch> - <Match when={diffs().length}>Summary</Match> - <Match when={true}>Response</Match> - </Switch> - </h2> - <Show when={summary()}> - {(summary) => ( - <Markdown - classList={{ "[&>*]:fade-up-text": !diffs().length }} - text={summary()} - /> - )} - </Show> - </div> - <Accordion class="w-full" multiple> - <For each={diffs()}> - {(diff) => ( - <Accordion.Item value={diff.file}> - <Accordion.Header> - <Accordion.Trigger onClick={handleDiffTriggerClick}> - <div class="flex items-center justify-between w-full gap-5"> - <div class="grow flex items-center gap-5 min-w-0"> - <FileIcon - node={{ path: diff.file, type: "file" }} - class="shrink-0 size-4" - /> - <div class="flex grow min-w-0"> - <Show when={diff.file.includes("/")}> - <span class="text-text-base truncate-start"> - {getDirectory(diff.file)}‎ - </span> - </Show> - <span class="text-text-strong shrink-0"> - {getFilename(diff.file)} - </span> - </div> - </div> - <div class="shrink-0 flex gap-4 items-center justify-end"> - <DiffChanges diff={diff} /> - <Icon name="chevron-grabber-vertical" size="small" /> - </div> - </div> - </Accordion.Trigger> - </Accordion.Header> - <Accordion.Content> - <Diff - before={{ - name: diff.file!, - contents: diff.before!, - }} - after={{ - name: diff.file!, - contents: diff.after!, - }} - /> - </Accordion.Content> - </Accordion.Item> - )} - </For> - </Accordion> - </div> - </Show> - <Show when={error() && !expanded()}> - <Card variant="error" class="text-text-on-critical-base"> - {error()?.data?.message as string} - </Card> - </Show> - {/* Response */} - <div class="w-full"> - <Switch> - <Match when={!completed()}> - <MessageProgress - assistantMessages={assistantMessages} - done={!working()} - /> - </Match> - <Match when={completed() && hasToolPart()}> - <Collapsible variant="ghost" open={expanded()} onOpenChange={setExpanded}> - <Collapsible.Trigger class="text-text-weak hover:text-text-strong"> - <div class="flex items-center gap-1 self-stretch"> - <div class="text-12-medium"> - <Switch> - <Match when={expanded()}>Hide details</Match> - <Match when={!expanded()}>Show details</Match> - </Switch> - </div> - <Collapsible.Arrow /> - </div> - </Collapsible.Trigger> - <Collapsible.Content> - <div class="w-full flex flex-col items-start self-stretch gap-3"> - <For each={assistantMessages()}> - {(assistantMessage) => { - const parts = createMemo( - () => sync.data.part[assistantMessage.id], - ) - return <Message message={assistantMessage} parts={parts()} /> - }} - </For> - <Show when={error()}> - <Card variant="error" class="text-text-on-critical-base"> - {error()?.data?.message as string} - </Card> - </Show> - </div> - </Collapsible.Content> - </Collapsible> - </Match> - </Switch> - </div> - </div> - </Show> - ) - }} - </For> - </div> - </div> - </div> - ) - }} - </Show> - </div> - </Tabs.Content> - {/* <Tabs.Content value="review" class="select-text"></Tabs.Content> */} - <For each={local.session.tabs()?.opened}> - {(file) => ( - <Tabs.Content value={file} class="select-text"> - {(() => { - { - /* const view = local.file.view(file) */ - } - { - /* const showRaw = view === "raw" || !file.content?.diff */ - } - { - /* const code = showRaw ? (file.content?.content ?? "") : (file.content?.diff ?? "") */ - } - const node = local.file.node(file) - return ( - <Code - file={{ name: node.path, contents: node.content?.content ?? "" }} - disableFileHeader - overflow="scroll" - class="pt-3 pb-40" - /> - ) - })()} - </Tabs.Content> - )} - </For> - </Tabs> - <DragOverlay> - {(() => { - const id = activeItem() - if (!id) return null - const draggedFile = local.file.node(id) - if (!draggedFile) return null - return ( - <div class="relative px-3 h-10 flex items-center bg-background-base border-x border-border-weak-base border-b border-b-transparent"> - <TabVisual file={draggedFile} /> - </div> - ) - })()} - </DragOverlay> - </DragDropProvider> - <div class="absolute inset-x-0 px-6 max-w-2xl flex flex-col justify-center items-center z-50 mx-auto bottom-8"> - <PromptInput - ref={(el) => { - inputRef = el - }} - onSubmit={handlePromptSubmit} - /> - </div> - <div class="hidden shrink-0 w-56 p-2 h-full overflow-y-auto"> - <FileTree path="" onFileClick={handleFileClick} /> - </div> - <div class="hidden shrink-0 w-56 p-2"> - <Show - when={local.file.changes().length} - fallback={<div class="px-2 text-xs text-text-muted">No changes</div>} - > - <ul class=""> - <For each={local.file.changes()}> - {(path) => ( - <li> - <button - onClick={() => local.file.open(path, { view: "diff-unified", pinned: true })} - class="w-full flex items-center px-2 py-0.5 gap-x-2 text-text-muted grow min-w-0 hover:bg-background-element" - > - <FileIcon node={{ path, type: "file" }} class="shrink-0 size-3" /> - <span class="text-xs text-text whitespace-nowrap">{getFilename(path)}</span> - <span class="text-xs text-text-muted/60 whitespace-nowrap truncate min-w-0"> - {getDirectory(path)} - </span> - </button> - </li> - )} - </For> - </ul> - </Show> - </div> - </div> - </main> - <Show when={store.fileSelectOpen}> - <SelectDialog - defaultOpen - title="Select file" - placeholder="Search files" - emptyMessage="No files found" - items={local.file.searchFiles} - key={(x) => x} - onOpenChange={(open) => setStore("fileSelectOpen", open)} - onSelect={(x) => (x ? local.session.open(x) : undefined)} - > - {(i) => ( - <div - classList={{ - "w-full flex items-center justify-between rounded-md": true, - }} - > - <div class="flex items-center gap-x-2 grow min-w-0"> - <FileIcon node={{ path: i, type: "file" }} class="shrink-0 size-4" /> - <div class="flex items-center text-14-regular"> - <span class="text-text-weak whitespace-nowrap overflow-hidden overflow-ellipsis truncate min-w-0"> - {getDirectory(i)} - </span> - <span class="text-text-strong whitespace-nowrap">{getFilename(i)}</span> - </div> - </div> - <div class="flex items-center gap-x-1 text-text-muted/40 shrink-0"></div> - </div> - )} - </SelectDialog> - </Show> - </div> - ) -} diff --git a/packages/desktop/src/pages/layout.tsx b/packages/desktop/src/pages/layout.tsx new file mode 100644 index 000000000..afec9ee3c --- /dev/null +++ b/packages/desktop/src/pages/layout.tsx @@ -0,0 +1,75 @@ +import { Button, Tooltip, DiffChanges } from "@opencode-ai/ui" +import { createMemo, ParentProps } from "solid-js" +import { getFilename } from "@/utils" +import { DateTime } from "luxon" +import { useSync } from "@/context/sync" +import { VList } from "virtua/solid" +import { A, useParams } from "@solidjs/router" + +export default function Layout(props: ParentProps) { + const params = useParams() + const sync = useSync() + return ( + <div class="relative h-screen flex flex-col"> + <header class="hidden h-12 shrink-0 bg-background-strong border-b border-border-weak-base"></header> + <div class="h-[calc(100vh-0rem)] flex"> + <div class="w-70 shrink-0 bg-background-weak border-r border-border-weak-base flex flex-col items-start"> + <div class="h-10 flex items-center self-stretch px-5 border-b border-border-weak-base"> + <span class="text-14-regular overflow-hidden text-ellipsis">{getFilename(sync.data.path.directory)}</span> + </div> + <div class="flex flex-col items-start gap-4 self-stretch flex-1 py-4 px-3"> + <A href="/session" class="w-full"> + <Button class="w-full" size="large" icon="edit-small-2"> + New Session + </Button> + </A> + <VList data={sync.data.session} class="no-scrollbar"> + {(session) => { + const diffs = createMemo(() => session.summary?.diffs ?? []) + const filesChanged = createMemo(() => diffs().length) + const updated = createMemo(() => DateTime.fromMillis(session.time.updated)) + return ( + <A + data-active={session.id === params.id} + href={`/session/${session.id}`} + class="group/session focus:outline-none" + > + <Tooltip placement="right" value={session.title}> + <div + class="w-full mb-1.5 px-3 py-1 rounded-md + group-data-[active=true]/session:bg-surface-raised-base-hover + group-hover/session:bg-surface-raised-base-hover + group-focus/session:bg-surface-raised-base-hover" + > + <div class="flex items-center self-stretch gap-6 justify-between"> + <span class="text-14-regular text-text-strong overflow-hidden text-ellipsis truncate"> + {session.title} + </span> + <span class="text-12-regular text-text-weak text-right whitespace-nowrap"> + {Math.abs(updated().diffNow().as("seconds")) < 60 + ? "Now" + : updated() + .toRelative({ style: "short", unit: ["days", "hours", "minutes"] }) + ?.replace(" ago", "") + ?.replace(/ days?/, "d") + ?.replace(" min.", "m") + ?.replace(" hr.", "h")} + </span> + </div> + <div class="flex justify-between items-center self-stretch"> + <span class="text-12-regular text-text-weak">{`${filesChanged() || "No"} file${filesChanged() !== 1 ? "s" : ""} changed`}</span> + <DiffChanges diff={diffs()} /> + </div> + </div> + </Tooltip> + </A> + ) + }} + </VList> + </div> + </div> + <main class="size-full overflow-x-hidden">{props.children}</main> + </div> + </div> + ) +} diff --git a/packages/desktop/src/pages/session-layout.tsx b/packages/desktop/src/pages/session-layout.tsx new file mode 100644 index 000000000..9a24608f0 --- /dev/null +++ b/packages/desktop/src/pages/session-layout.tsx @@ -0,0 +1,12 @@ +import { Show, type ParentProps } from "solid-js" +import { SessionProvider } from "@/context/session" +import { useParams } from "@solidjs/router" + +export default function Layout(props: ParentProps) { + const params = useParams() + return ( + <Show when={params.id || true} keyed> + <SessionProvider sessionId={params.id}>{props.children}</SessionProvider> + </Show> + ) +} diff --git a/packages/desktop/src/pages/session.tsx b/packages/desktop/src/pages/session.tsx new file mode 100644 index 000000000..9c633f4f7 --- /dev/null +++ b/packages/desktop/src/pages/session.tsx @@ -0,0 +1,693 @@ +import { + SelectDialog, + IconButton, + Tabs, + Icon, + Accordion, + Diff, + Collapsible, + DiffChanges, + Message, + Typewriter, + Card, + Code, + Tooltip, + ProgressCircle, +} from "@opencode-ai/ui" +import { FileIcon } from "@/ui" +import { MessageProgress } from "@/components/message-progress" +import { + For, + onCleanup, + onMount, + Show, + Match, + Switch, + createSignal, + createEffect, + createMemo, + createResource, +} from "solid-js" +import { useLocal, type LocalFile } from "@/context/local" +import { createStore } from "solid-js/store" +import { getDirectory, getFilename } from "@/utils" +import { PromptInput } from "@/components/prompt-input" +import { DateTime } from "luxon" +import { + DragDropProvider, + DragDropSensors, + DragOverlay, + SortableProvider, + closestCenter, + createSortable, + useDragDropContext, +} from "@thisbeyond/solid-dnd" +import type { DragEvent, Transformer } from "@thisbeyond/solid-dnd" +import type { JSX } from "solid-js" +import { useSync } from "@/context/sync" +import { type AssistantMessage as AssistantMessageType } from "@opencode-ai/sdk" +import { Markdown } from "@opencode-ai/ui" +import { Spinner } from "@/components/spinner" +import { useSession } from "@/context/session" + +export default function Page() { + const local = useLocal() + const sync = useSync() + const session = useSession() + const [store, setStore] = createStore({ + clickTimer: undefined as number | undefined, + fileSelectOpen: false, + activeDraggable: undefined as string | undefined, + }) + let inputRef!: HTMLDivElement + let messageScrollElement!: HTMLDivElement + + const MOD = typeof navigator === "object" && /(Mac|iPod|iPhone|iPad)/.test(navigator.platform) ? "Meta" : "Control" + + onMount(() => { + document.addEventListener("keydown", handleKeyDown) + }) + + onCleanup(() => { + document.removeEventListener("keydown", handleKeyDown) + }) + + const handleKeyDown = (event: KeyboardEvent) => { + if (event.getModifierState(MOD) && event.shiftKey && event.key.toLowerCase() === "p") { + event.preventDefault() + return + } + if (event.getModifierState(MOD) && event.key.toLowerCase() === "p") { + event.preventDefault() + setStore("fileSelectOpen", true) + return + } + + const focused = document.activeElement === inputRef + if (focused) { + if (event.key === "Escape") { + inputRef?.blur() + } + return + } + + // if (local.file.active()) { + // const active = local.file.active()! + // if (event.key === "Enter" && active.selection) { + // local.context.add({ + // type: "file", + // path: active.path, + // selection: { ...active.selection }, + // }) + // return + // } + // + // if (event.getModifierState(MOD)) { + // if (event.key.toLowerCase() === "a") { + // return + // } + // if (event.key.toLowerCase() === "c") { + // return + // } + // } + // } + + if (event.key.length === 1 && event.key !== "Unidentified" && !(event.ctrlKey || event.metaKey)) { + inputRef?.focus() + } + } + + const resetClickTimer = () => { + if (!store.clickTimer) return + clearTimeout(store.clickTimer) + setStore("clickTimer", undefined) + } + + const startClickTimer = () => { + const newClickTimer = setTimeout(() => { + setStore("clickTimer", undefined) + }, 300) + setStore("clickTimer", newClickTimer as unknown as number) + } + + const handleTabClick = async (tab: string) => { + if (store.clickTimer) { + resetClickTimer() + // local.file.update(file.path, { ...file, pinned: true }) + } else { + if (tab.startsWith("file://")) { + local.file.open(tab.replace("file://", "")) + } + startClickTimer() + } + } + + const handleDragStart = (event: unknown) => { + const id = getDraggableId(event) + if (!id) return + setStore("activeDraggable", id) + } + + const handleDragOver = (event: DragEvent) => { + const { draggable, droppable } = event + if (draggable && droppable) { + const currentTabs = session.layout.tabs.opened + const fromIndex = currentTabs?.indexOf(draggable.id.toString()) + const toIndex = currentTabs?.indexOf(droppable.id.toString()) + if (fromIndex !== toIndex && toIndex !== undefined) { + session.layout.moveTab(draggable.id.toString(), toIndex) + } + } + } + + const handleDragEnd = () => { + setStore("activeDraggable", undefined) + } + + const FileVisual = (props: { file: LocalFile }): JSX.Element => { + return ( + <div class="flex items-center gap-x-1.5"> + <FileIcon node={props.file} class="grayscale-100 group-data-[selected]/tab:grayscale-0" /> + <span + classList={{ + "text-14-medium": true, + "text-primary": !!props.file.status?.status, + italic: !props.file.pinned, + }} + > + {props.file.name} + </span> + <span class="hidden opacity-70"> + <Switch> + <Match when={props.file.status?.status === "modified"}> + <span class="text-primary">M</span> + </Match> + <Match when={props.file.status?.status === "added"}> + <span class="text-success">A</span> + </Match> + <Match when={props.file.status?.status === "deleted"}> + <span class="text-error">D</span> + </Match> + </Switch> + </span> + </div> + ) + } + + const SortableTab = (props: { + tab: string + onTabClick: (tab: string) => void + onTabClose: (tab: string) => void + }): JSX.Element => { + const sortable = createSortable(props.tab) + + const [file] = createResource( + () => props.tab, + async (tab) => { + if (tab.startsWith("file://")) { + return local.file.node(tab.replace("file://", "")) + } + return undefined + }, + ) + + return ( + // @ts-ignore + <div use:sortable classList={{ "h-full": true, "opacity-0": sortable.isActiveDraggable }}> + <div class="relative h-full"> + <Tabs.Trigger value={props.tab} class="group/tab pl-3 pr-1" onClick={() => props.onTabClick(props.tab)}> + <Switch> + <Match when={file()}>{(f) => <FileVisual file={f()} />}</Match> + </Switch> + <IconButton + icon="close" + class="mt-0.5 opacity-0 text-text-muted/60 group-data-[selected]/tab:opacity-100 + group-data-[selected]/tab:text-text group-data-[selected]/tab:hover:bg-border-subtle + hover:opacity-100 group-hover/tab:opacity-100" + variant="ghost" + onClick={() => props.onTabClose(props.tab)} + /> + </Tabs.Trigger> + </div> + </div> + ) + } + + const ConstrainDragYAxis = (): JSX.Element => { + const context = useDragDropContext() + if (!context) return <></> + const [, { onDragStart, onDragEnd, addTransformer, removeTransformer }] = context + const transformer: Transformer = { + id: "constrain-y-axis", + order: 100, + callback: (transform) => ({ ...transform, y: 0 }), + } + onDragStart((event) => { + const id = getDraggableId(event) + if (!id) return + addTransformer("draggables", id, transformer) + }) + onDragEnd((event) => { + const id = getDraggableId(event) + if (!id) return + removeTransformer("draggables", id, transformer.id) + }) + return <></> + } + + const getDraggableId = (event: unknown): string | undefined => { + if (typeof event !== "object" || event === null) return undefined + if (!("draggable" in event)) return undefined + const draggable = (event as { draggable?: { id?: unknown } }).draggable + if (!draggable) return undefined + return typeof draggable.id === "string" ? draggable.id : undefined + } + + return ( + <div class="relative bg-background-base size-full overflow-x-hidden"> + <DragDropProvider + onDragStart={handleDragStart} + onDragEnd={handleDragEnd} + onDragOver={handleDragOver} + collisionDetector={closestCenter} + > + <DragDropSensors /> + <ConstrainDragYAxis /> + <Tabs value={session.layout.tabs.active ?? "chat"} onChange={session.layout.openTab}> + <div class="sticky top-0 shrink-0 flex"> + <Tabs.List> + <Tabs.Trigger value="chat" class="flex gap-x-4 items-center"> + <div>Chat</div> + <Tooltip + value={`${new Intl.NumberFormat("en-US", { + notation: "compact", + compactDisplay: "short", + }).format(session.usage.tokens() ?? 0)} Tokens`} + class="flex items-center gap-1.5" + > + <ProgressCircle percentage={session.usage.context() ?? 0} /> + <div class="text-14-regular text-text-weak text-left w-7">{session.usage.context() ?? 0}%</div> + </Tooltip> + </Tabs.Trigger> + {/* <Tabs.Trigger value="review">Review</Tabs.Trigger> */} + <SortableProvider ids={session.layout.tabs.opened ?? []}> + <For each={session.layout.tabs.opened ?? []}> + {(tab) => <SortableTab tab={tab} onTabClick={handleTabClick} onTabClose={session.layout.closeTab} />} + </For> + </SortableProvider> + <div class="bg-background-base h-full flex items-center justify-center border-b border-border-weak-base px-3"> + <IconButton + icon="plus-small" + variant="ghost" + iconSize="large" + onClick={() => setStore("fileSelectOpen", true)} + /> + </div> + </Tabs.List> + </div> + <Tabs.Content value="chat" class="@container select-text flex flex-col flex-1 min-h-0 overflow-y-hidden"> + <div class="relative px-6 pt-12 max-w-2xl w-full mx-auto flex flex-col flex-1 min-h-0"> + <Show + when={session.id} + fallback={ + <div class="flex flex-col pb-45 justify-end items-start gap-4 flex-[1_0_0] self-stretch"> + <div class="text-20-medium text-text-weaker">New session</div> + <div class="flex justify-center items-center gap-3"> + <Icon name="folder" size="small" /> + <div class="text-12-medium text-text-weak"> + {getDirectory(sync.data.path.directory)} + <span class="text-text-strong">{getFilename(sync.data.path.directory)}</span> + </div> + </div> + <div class="flex justify-center items-center gap-3"> + <Icon name="pencil-line" size="small" /> + <div class="text-12-medium text-text-weak"> + Last modified + <span class="text-text-strong"> + {DateTime.fromMillis(sync.data.project.time.created).toRelative()} + </span> + </div> + </div> + </div> + } + > + {(_) => { + return ( + <div class="pt-3 flex flex-col flex-1 min-h-0"> + <div class="flex-1 min-h-0"> + <Show when={session.messages.user().length > 1}> + <ul + role="list" + class="absolute right-full mr-8 hidden w-60 shrink-0 @7xl:flex flex-col items-start gap-1" + > + <For each={session.messages.user()}> + {(message) => { + const assistantMessages = createMemo(() => { + if (!session.id) return [] + return sync.data.message[session.id]?.filter( + (m) => m.role === "assistant" && m.parentID == message.id, + ) as AssistantMessageType[] + }) + const error = createMemo(() => assistantMessages().find((m) => m?.error)?.error) + const working = createMemo(() => !message.summary?.body && !error()) + + return ( + <li class="group/li flex items-center self-stretch"> + <button + class="flex items-center self-stretch w-full gap-x-2 py-1 cursor-default" + onClick={() => session.messages.setActive(message.id)} + > + <Switch> + <Match when={working()}> + <Spinner class="text-text-base shrink-0 w-[18px] aspect-square" /> + </Match> + <Match when={true}> + <DiffChanges diff={message.summary?.diffs ?? []} variant="bars" /> + </Match> + </Switch> + <div + data-active={session.messages.active()?.id === message.id} + classList={{ + "text-14-regular text-text-weak whitespace-nowrap truncate min-w-0": true, + "text-text-weak data-[active=true]:text-text-strong group-hover/li:text-text-base": true, + }} + > + <Show when={message.summary?.title} fallback="New message"> + {message.summary?.title} + </Show> + </div> + </button> + </li> + ) + }} + </For> + </ul> + </Show> + <div ref={messageScrollElement} class="grow min-w-0 h-full overflow-y-auto no-scrollbar"> + <For each={session.messages.user()}> + {(message) => { + const isActive = createMemo(() => session.messages.active()?.id === message.id) + const [titled, setTitled] = createSignal(!!message.summary?.title) + const assistantMessages = createMemo(() => { + if (!session.id) return [] + return sync.data.message[session.id]?.filter( + (m) => m.role === "assistant" && m.parentID == message.id, + ) as AssistantMessageType[] + }) + const error = createMemo(() => assistantMessages().find((m) => m?.error)?.error) + const [completed, setCompleted] = createSignal(!!message.summary?.body || !!error()) + const [detailsExpanded, setDetailsExpanded] = createSignal(false) + const parts = createMemo(() => sync.data.part[message.id]) + const hasToolPart = createMemo(() => + assistantMessages() + ?.flatMap((m) => sync.data.part[m.id]) + .some((p) => p?.type === "tool"), + ) + const working = createMemo(() => !message.summary?.body && !error()) + + // allowing time for the animations to finish + createEffect(() => { + const title = message.summary?.title + setTimeout(() => setTitled(!!title), 10_000) + }) + createEffect(() => { + const summary = message.summary?.body + const complete = !!summary || !!error() + setTimeout(() => setCompleted(complete), 1200) + }) + + return ( + <Show when={isActive()}> + <div + data-message={message.id} + class="flex flex-col items-start self-stretch gap-8 pb-50" + > + {/* Title */} + <div class="py-2 flex flex-col items-start gap-2 self-stretch sticky top-0 bg-background-stronger z-10"> + <div class="w-full text-14-medium text-text-strong"> + <Show + when={titled()} + fallback={ + <Typewriter + as="h1" + text={message.summary?.title} + class="overflow-hidden text-ellipsis min-w-0 text-nowrap" + /> + } + > + <h1 class="overflow-hidden text-ellipsis min-w-0 text-nowrap"> + {message.summary?.title} + </h1> + </Show> + </div> + </div> + <div class="-mt-8"> + <Message message={message} parts={parts()} /> + </div> + {/* Summary */} + <Show when={completed()}> + <div class="w-full flex flex-col gap-6 items-start self-stretch"> + <div class="flex flex-col items-start gap-1 self-stretch"> + <h2 class="text-12-medium text-text-weak"> + <Switch> + <Match when={message.summary?.diffs?.length}>Summary</Match> + <Match when={true}>Response</Match> + </Switch> + </h2> + <Show when={message.summary?.body}> + {(summary) => ( + <Markdown + classList={{ + "text-14-regular": !!message.summary?.diffs?.length, + "[&>*]:fade-up-text": !message.summary?.diffs?.length, + }} + text={summary()} + /> + )} + </Show> + </div> + <Accordion class="w-full" multiple> + <For each={message.summary?.diffs ?? []}> + {(diff) => ( + <Accordion.Item value={diff.file}> + <Accordion.Header> + <Accordion.Trigger> + <div class="flex items-center justify-between w-full gap-5"> + <div class="grow flex items-center gap-5 min-w-0"> + <FileIcon + node={{ path: diff.file, type: "file" }} + class="shrink-0 size-4" + /> + <div class="flex grow min-w-0"> + <Show when={diff.file.includes("/")}> + <span class="text-text-base truncate-start"> + {getDirectory(diff.file)}‎ + </span> + </Show> + <span class="text-text-strong shrink-0"> + {getFilename(diff.file)} + </span> + </div> + </div> + <div class="shrink-0 flex gap-4 items-center justify-end"> + <DiffChanges diff={diff} /> + <Icon name="chevron-grabber-vertical" size="small" /> + </div> + </div> + </Accordion.Trigger> + </Accordion.Header> + <Accordion.Content> + <Diff + before={{ + name: diff.file!, + contents: diff.before!, + }} + after={{ + name: diff.file!, + contents: diff.after!, + }} + /> + </Accordion.Content> + </Accordion.Item> + )} + </For> + </Accordion> + </div> + </Show> + <Show when={error() && !detailsExpanded()}> + <Card variant="error" class="text-text-on-critical-base"> + {error()?.data?.message as string} + </Card> + </Show> + {/* Response */} + <div class="w-full"> + <Switch> + <Match when={!completed()}> + <MessageProgress assistantMessages={assistantMessages} done={!working()} /> + </Match> + <Match when={completed() && hasToolPart()}> + <Collapsible + variant="ghost" + open={detailsExpanded()} + onOpenChange={setDetailsExpanded} + > + <Collapsible.Trigger class="text-text-weak hover:text-text-strong"> + <div class="flex items-center gap-1 self-stretch"> + <div class="text-12-medium"> + <Switch> + <Match when={detailsExpanded()}>Hide details</Match> + <Match when={!detailsExpanded()}>Show details</Match> + </Switch> + </div> + <Collapsible.Arrow /> + </div> + </Collapsible.Trigger> + <Collapsible.Content> + <div class="w-full flex flex-col items-start self-stretch gap-3"> + <For each={assistantMessages()}> + {(assistantMessage) => { + const parts = createMemo(() => sync.data.part[assistantMessage.id]) + return <Message message={assistantMessage} parts={parts()} /> + }} + </For> + <Show when={error()}> + <Card variant="error" class="text-text-on-critical-base"> + {error()?.data?.message as string} + </Card> + </Show> + </div> + </Collapsible.Content> + </Collapsible> + </Match> + </Switch> + </div> + </div> + </Show> + ) + }} + </For> + </div> + </div> + </div> + ) + }} + </Show> + </div> + </Tabs.Content> + {/* <Tabs.Content value="review" class="select-text"></Tabs.Content> */} + <For each={session.layout.tabs.opened}> + {(tab) => { + const [file] = createResource( + () => tab, + async (tab) => { + if (tab.startsWith("file://")) { + return local.file.node(tab.replace("file://", "")) + } + return undefined + }, + ) + return ( + <Tabs.Content value={tab} class="select-text"> + <Switch> + <Match when={file()}> + {(f) => ( + <Code + file={{ name: f().path, contents: f().content?.content ?? "" }} + overflow="scroll" + class="pt-3 pb-40" + /> + )} + </Match> + </Switch> + </Tabs.Content> + ) + }} + </For> + </Tabs> + <DragOverlay> + <Show when={store.activeDraggable}> + {(draggedFile) => { + const [file] = createResource( + () => draggedFile(), + async (tab) => { + if (tab.startsWith("file://")) { + return local.file.node(tab.replace("file://", "")) + } + return undefined + }, + ) + return ( + <div class="relative px-3 h-10 flex items-center bg-background-base border-x border-border-weak-base border-b border-b-transparent"> + <Show when={file()}>{(f) => <FileVisual file={f()} />}</Show> + </div> + ) + }} + </Show> + </DragOverlay> + </DragDropProvider> + <div class="absolute inset-x-0 px-6 max-w-2xl flex flex-col justify-center items-center z-50 mx-auto bottom-8"> + <PromptInput + ref={(el) => { + inputRef = el + }} + /> + </div> + <div class="hidden shrink-0 w-56 p-2 h-full overflow-y-auto"> + {/* <FileTree path="" onFileClick={ handleTabClick} /> */} + </div> + <div class="hidden shrink-0 w-56 p-2"> + <Show when={local.file.changes().length} fallback={<div class="px-2 text-xs text-text-muted">No changes</div>}> + <ul class=""> + <For each={local.file.changes()}> + {(path) => ( + <li> + <button + onClick={() => local.file.open(path, { view: "diff-unified", pinned: true })} + class="w-full flex items-center px-2 py-0.5 gap-x-2 text-text-muted grow min-w-0 hover:bg-background-element" + > + <FileIcon node={{ path, type: "file" }} class="shrink-0 size-3" /> + <span class="text-xs text-text whitespace-nowrap">{getFilename(path)}</span> + <span class="text-xs text-text-muted/60 whitespace-nowrap truncate min-w-0"> + {getDirectory(path)} + </span> + </button> + </li> + )} + </For> + </ul> + </Show> + </div> + <Show when={store.fileSelectOpen}> + <SelectDialog + defaultOpen + title="Select file" + placeholder="Search files" + emptyMessage="No files found" + items={local.file.searchFiles} + key={(x) => x} + onOpenChange={(open) => setStore("fileSelectOpen", open)} + onSelect={(x) => (x ? session.layout.openTab("file://" + x) : undefined)} + > + {(i) => ( + <div + classList={{ + "w-full flex items-center justify-between rounded-md": true, + }} + > + <div class="flex items-center gap-x-2 grow min-w-0"> + <FileIcon node={{ path: i, type: "file" }} class="shrink-0 size-4" /> + <div class="flex items-center text-14-regular"> + <span class="text-text-weak whitespace-nowrap overflow-hidden overflow-ellipsis truncate min-w-0"> + {getDirectory(i)} + </span> + <span class="text-text-strong whitespace-nowrap">{getFilename(i)}</span> + </div> + </div> + <div class="flex items-center gap-x-1 text-text-muted/40 shrink-0"></div> + </div> + )} + </SelectDialog> + </Show> + </div> + ) +} |
