diff options
| author | Adam <[email protected]> | 2025-09-26 11:41:15 -0500 |
|---|---|---|
| committer | Adam <[email protected]> | 2025-10-02 08:34:01 -0500 |
| commit | cc955098cd8714bcf1cc91e6a4a6625e38710b05 (patch) | |
| tree | 49adeef653a6705c37b2e06cde1b5066765eeed3 /packages/app/src/pages | |
| parent | 8699e896e604762d45df7d4e1b3433e69575e9ab (diff) | |
| download | opencode-cc955098cd8714bcf1cc91e6a4a6625e38710b05.tar.gz opencode-cc955098cd8714bcf1cc91e6a4a6625e38710b05.zip | |
wip: desktop work
Diffstat (limited to 'packages/app/src/pages')
| -rw-r--r-- | packages/app/src/pages/index.tsx | 636 |
1 files changed, 103 insertions, 533 deletions
diff --git a/packages/app/src/pages/index.tsx b/packages/app/src/pages/index.tsx index 08c2a4a39..128c6adfd 100644 --- a/packages/app/src/pages/index.tsx +++ b/packages/app/src/pages/index.tsx @@ -1,21 +1,11 @@ -import { Button, FileIcon, Icon, IconButton, Logo, Tooltip } from "@/ui" +import { FileIcon, Icon, IconButton, Tooltip } from "@/ui" import { Tabs } from "@/ui/tabs" -import { Select } from "@/components/select" import FileTree from "@/components/file-tree" +import EditorPane from "@/components/editor-pane" import { For, Match, onCleanup, onMount, Show, Switch } from "solid-js" import { SelectDialog } from "@/components/select-dialog" -import { useLocal, useSDK } from "@/context" -import { Code } from "@/components/code" -import { - DragDropProvider, - DragDropSensors, - DragOverlay, - SortableProvider, - createSortable, - closestCenter, - useDragDropContext, -} from "@thisbeyond/solid-dnd" -import type { DragEvent, Transformer } from "@thisbeyond/solid-dnd" +import { useLocal } from "@/context" +import { ResizeableLayout, ResizeablePane } from "@/components/resizeable-pane" import type { LocalFile } from "@/context/local" import SessionList from "@/components/session-list" import SessionTimeline from "@/components/session-timeline" @@ -23,18 +13,17 @@ import { createStore } from "solid-js/store" import { getDirectory, getFilename } from "@/utils" export default function Page() { - const sdk = useSDK() const local = useLocal() const [store, setStore] = createStore({ clickTimer: undefined as number | undefined, - activeItem: undefined as string | undefined, - prompt: "", - dragging: undefined as "left" | "right" | undefined, modelSelectOpen: false, fileSelectOpen: false, }) - let inputRef: HTMLInputElement | undefined = undefined + const layoutKey = "workspace" + const timelinePane = "timeline" + + let inputRef: HTMLTextAreaElement | undefined = undefined const MOD = typeof navigator === "object" && /(Mac|iPod|iPhone|iPad)/.test(navigator.platform) ? "Meta" : "Control" @@ -46,54 +35,52 @@ export default function Page() { document.removeEventListener("keydown", handleKeyDown) }) - const handleKeyDown = (e: KeyboardEvent) => { - if (e.getModifierState(MOD) && e.shiftKey && e.key.toLowerCase() === "p") { - e.preventDefault() + const handleKeyDown = (event: KeyboardEvent) => { + if (event.getModifierState(MOD) && event.shiftKey && event.key.toLowerCase() === "p") { + event.preventDefault() // TODO: command palette return } - if (e.getModifierState(MOD) && e.key.toLowerCase() === "p") { - e.preventDefault() + if (event.getModifierState(MOD) && event.key.toLowerCase() === "p") { + event.preventDefault() setStore("fileSelectOpen", true) return } - const inputFocused = document.activeElement === inputRef - if (inputFocused) { - if (e.key === "Escape") { + const focused = document.activeElement === inputRef + if (focused) { + if (event.key === "Escape") { inputRef?.blur() } return } - if (document.activeElement?.id === "select-filter") { - return - } - if (local.file.active()) { - if (e.getModifierState(MOD)) { - if (e.key.toLowerCase() === "a") { + 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 (e.key.toLowerCase() === "c") { + if (event.key.toLowerCase() === "c") { return } } } - if (e.key.length === 1 && e.key !== "Unidentified") { + if (event.key.length === 1 && event.key !== "Unidentified") { inputRef?.focus() } } - 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 resetClickTimer = () => { if (!store.clickTimer) return clearTimeout(store.clickTimer) @@ -117,190 +104,80 @@ export default function Page() { } } - const handleTabChange = (path: string) => { - local.file.open(path) - } - - const handleTabClose = (file: LocalFile) => { - local.file.close(file.path) - } - - const onDragStart = (event: any) => { - setStore("activeItem", event.draggable.id as string) - } - - const onDragOver = (event: DragEvent) => { - const { draggable, droppable } = event - if (draggable && droppable) { - const currentFiles = local.file.opened().map((f) => f.path) - const fromIndex = currentFiles.indexOf(draggable.id.toString()) - const toIndex = currentFiles.indexOf(droppable.id.toString()) - if (fromIndex !== toIndex) { - local.file.move(draggable.id.toString(), toIndex) - } - } - } - - const onDragEnd = () => { - setStore("activeItem", undefined) - } - - const handleLeftDragStart = (e: MouseEvent) => { - e.preventDefault() - setStore("dragging", "left") - const startX = e.clientX - const startWidth = local.layout.leftWidth() - - const handleMouseMove = (e: MouseEvent) => { - const deltaX = e.clientX - startX - const newWidth = startWidth + deltaX - local.layout.setLeftWidth(newWidth) - } - - const handleMouseUp = () => { - setStore("dragging", undefined) - document.removeEventListener("mousemove", handleMouseMove) - document.removeEventListener("mouseup", handleMouseUp) - } - - document.addEventListener("mousemove", handleMouseMove) - document.addEventListener("mouseup", handleMouseUp) - } - - const handleRightDragStart = (e: MouseEvent) => { - e.preventDefault() - setStore("dragging", "right") - const startX = e.clientX - const startWidth = local.layout.rightWidth() - - const handleMouseMove = (e: MouseEvent) => { - const deltaX = startX - e.clientX - const newWidth = startWidth + deltaX - local.layout.setRightWidth(newWidth) - } - - const handleMouseUp = () => { - setStore("dragging", undefined) - document.removeEventListener("mousemove", handleMouseMove) - document.removeEventListener("mouseup", handleMouseUp) - } - - document.addEventListener("mousemove", handleMouseMove) - document.addEventListener("mouseup", handleMouseUp) - } - - const handleSubmit = async (e: SubmitEvent) => { - e.preventDefault() - const prompt = store.prompt - setStore("prompt", "") - inputRef?.blur() - - const session = - (local.layout.rightPane() ? local.session.active() : undefined) ?? - (await sdk.session.create().then((x) => x.data!)) - local.session.setActive(session!.id) - local.layout.openRightPane() - - const response = await sdk.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: prompt, - }, - ...local.file - .opened() - .filter((f) => f.selection || local.file.active()?.path === f.path) - .flatMap((f) => [ - { - type: "file" as const, - mime: "text/plain", - url: `file://${f.absolute}${f.selection ? `?start=${f.selection.startLine}&end=${f.selection.endLine}` : ""}`, - filename: f.name, - source: { - type: "file" as const, - text: { - value: "@" + f.name, - start: 0, // f.start, - end: 0, // f.end, - }, - path: f.absolute, - }, - }, - ]), - ], - }, - }) - - console.log("response", response) - } - return ( <div class="relative"> - <div - class="fixed top-0 left-0 h-full border-r border-border-subtle/30 flex flex-col overflow-hidden bg-background z-10" - style={`width: ${local.layout.leftWidth()}px`} - > - <Tabs class="relative flex flex-col h-full" defaultValue="files"> - <div class="sticky top-0 shrink-0 flex"> - <Tabs.List class="grow w-full after:hidden"> - <Tabs.Trigger value="files" class="flex-1 justify-center text-xs"> - Files - </Tabs.Trigger> - <Tabs.Trigger value="changes" class="flex-1 justify-center text-xs"> - Changes - </Tabs.Trigger> - </Tabs.List> - </div> - <Tabs.Content value="files" class="grow min-h-0 py-2 bg-background"> - <FileTree path="" onFileClick={handleFileClick} /> - </Tabs.Content> - <Tabs.Content value="changes" class="grow min-h-0 py-2 bg-background"> - <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 cursor-pointer 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> - </Tabs.Content> - </Tabs> - </div> - <div - class="fixed top-0 h-full w-1.5 bg-transparent cursor-col-resize z-50 group" - style={`left: ${local.layout.leftWidth()}px`} - onMouseDown={(e) => handleLeftDragStart(e)} + <ResizeableLayout + id={layoutKey} + defaults={{ + explorer: { size: 24, visible: true }, + editor: { size: 56, visible: true }, + timeline: { size: 20, visible: false }, + }} + class="h-screen" > - <div - classList={{ - "w-0.5 h-full bg-transparent group-hover:bg-border-active transition-colors": true, - "bg-border-active!": store.dragging === "left", - }} - /> - </div> - <Show when={local.layout.rightPane()}> - <div - class="fixed top-0 right-0 h-full border-l border-border-subtle/30 flex flex-col overflow-hidden bg-background z-10" - style={`width: ${local.layout.rightWidth()}px`} + <ResizeablePane + id="explorer" + minSize="150px" + maxSize="300px" + class="border-r border-border-subtle/30 bg-background z-10 overflow-hidden" + > + <Tabs class="relative flex flex-col h-full" defaultValue="files"> + <div class="sticky top-0 shrink-0 flex"> + <Tabs.List class="grow w-full after:hidden"> + <Tabs.Trigger value="files" class="flex-1 justify-center text-xs"> + Files + </Tabs.Trigger> + <Tabs.Trigger value="changes" class="flex-1 justify-center text-xs"> + Changes + </Tabs.Trigger> + </Tabs.List> + </div> + <Tabs.Content value="files" class="grow min-h-0 py-2 bg-background"> + <FileTree path="" onFileClick={handleFileClick} /> + </Tabs.Content> + <Tabs.Content value="changes" class="grow min-h-0 py-2 bg-background"> + <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 cursor-pointer 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> + </Tabs.Content> + </Tabs> + </ResizeablePane> + <ResizeablePane id="editor" minSize={30} maxSize={80} class="bg-background"> + <EditorPane + layoutKey={layoutKey} + timelinePane={timelinePane} + onFileClick={handleFileClick} + onOpenModelSelect={() => setStore("modelSelectOpen", true)} + onInputRefChange={(element: HTMLTextAreaElement | null) => { + inputRef = element ?? undefined + }} + /> + </ResizeablePane> + <ResizeablePane + id="timeline" + minSize={20} + maxSize={40} + class="border-l border-border-subtle/30 bg-background z-10 overflow-hidden" > <div class="relative flex-1 min-h-0 overflow-y-auto overflow-x-hidden"> <Show when={local.session.active()} fallback={<SessionList />}> @@ -326,216 +203,8 @@ export default function Page() { )} </Show> </div> - </div> - <div - class="fixed top-0 h-full w-1.5 bg-transparent cursor-col-resize z-50 group flex justify-end" - style={`right: ${local.layout.rightWidth()}px`} - onMouseDown={(e) => handleRightDragStart(e)} - > - <div - classList={{ - "w-0.5 h-full bg-transparent group-hover:bg-border-active transition-colors": true, - "bg-border-active!": store.dragging === "right", - }} - /> - </div> - </Show> - <div - class="relative" - style={`margin-left: ${local.layout.leftWidth()}px; margin-right: ${local.layout.rightPane() ? local.layout.rightWidth() : 0}px`} - > - <Logo - size={64} - variant="ornate" - class="absolute top-2/5 left-1/2 transform -translate-x-1/2 -translate-y-1/2" - /> - <DragDropProvider - onDragStart={onDragStart} - onDragEnd={onDragEnd} - onDragOver={onDragOver} - collisionDetector={closestCenter} - > - <DragDropSensors /> - <ConstrainDragYAxis /> - <Tabs - class="relative grow w-full flex flex-col h-screen" - value={local.file.active()?.path} - onChange={handleTabChange} - > - <div class="sticky top-0 shrink-0 flex"> - <Tabs.List class="grow"> - <SortableProvider ids={local.file.opened().map((f) => f.path)}> - <For each={local.file.opened()}> - {(file) => <SortableTab file={file} onTabClick={handleFileClick} onTabClose={handleTabClose} />} - </For> - </SortableProvider> - </Tabs.List> - <div class="shrink-0 h-full flex items-center gap-1 px-2 border-b border-border-subtle/40"> - <Show when={local.file.active() && local.file.active()!.content?.diff}> - {(() => { - const f = local.file.active()! - const view = local.file.view(f.path) - return ( - <div class="flex items-center gap-1"> - <Show when={view !== "raw"}> - <div class="mr-1 flex items-center gap-1"> - <Tooltip value="Previous change" placement="bottom"> - <IconButton size="xs" variant="ghost" onClick={() => navigateChange(-1)}> - <Icon name="arrow-up" size={14} /> - </IconButton> - </Tooltip> - <Tooltip value="Next change" placement="bottom"> - <IconButton size="xs" variant="ghost" onClick={() => navigateChange(1)}> - <Icon name="arrow-down" size={14} /> - </IconButton> - </Tooltip> - </div> - </Show> - <Tooltip value="Raw" placement="bottom"> - <IconButton - size="xs" - variant="ghost" - classList={{ - "text-text": view === "raw", - "text-text-muted/70": view !== "raw", - "bg-background-element": view === "raw", - }} - onClick={() => local.file.setView(f.path, "raw")} - > - <Icon name="file-text" size={14} /> - </IconButton> - </Tooltip> - <Tooltip value="Unified diff" placement="bottom"> - <IconButton - size="xs" - variant="ghost" - classList={{ - "text-text": view === "diff-unified", - "text-text-muted/70": view !== "diff-unified", - "bg-background-element": view === "diff-unified", - }} - onClick={() => local.file.setView(f.path, "diff-unified")} - > - <Icon name="checklist" size={14} /> - </IconButton> - </Tooltip> - <Tooltip value="Split diff" placement="bottom"> - <IconButton - size="xs" - variant="ghost" - classList={{ - "text-text": view === "diff-split", - "text-text-muted/70": view !== "diff-split", - "bg-background-element": view === "diff-split", - }} - onClick={() => local.file.setView(f.path, "diff-split")} - > - <Icon name="columns" size={14} /> - </IconButton> - </Tooltip> - </div> - ) - })()} - </Show> - <Tooltip value={local.layout.rightPane() ? "Close pane" : "Open pane"} placement="bottom"> - <IconButton size="xs" variant="ghost" onClick={() => local.layout.toggleRightPane()}> - <Icon name={local.layout.rightPane() ? "close-pane" : "open-pane"} size={14} /> - </IconButton> - </Tooltip> - </div> - </div> - <For each={local.file.opened()}> - {(file) => ( - <Tabs.Content value={file.path} class="grow h-full pt-1 select-text"> - {(() => { - const view = local.file.view(file.path) - const showRaw = view === "raw" || !file.content?.diff - const code = showRaw ? (file.content?.content ?? "") : (file.content?.diff ?? "") - return <Code path={file.path} code={code} /> - })()} - </Tabs.Content> - )} - </For> - </Tabs> - <DragOverlay> - {store.activeItem && - (() => { - const draggedFile = local.file.node(store.activeItem!) - return ( - <div - class="relative px-3 h-8 flex items-center - text-sm font-medium text-text whitespace-nowrap - shrink-0 bg-background-panel - border-x border-border-subtle/40 border-b border-b-transparent" - > - <TabVisual file={draggedFile} /> - </div> - ) - })()} - </DragOverlay> - </DragDropProvider> - <form - onSubmit={handleSubmit} - class="peer/editor absolute inset-x-4 z-50 flex items-center justify-center" - classList={{ - "bottom-8": !!local.file.active(), - "bottom-2/5": local.file.active() === undefined, - }} - > - <div - class="w-full max-w-xl min-w-0 p-2 mx-auto rounded-lg isolate backdrop-blur-xs - flex flex-col gap-1 - bg-gradient-to-b from-background-panel/90 to-background/90 - ring-1 ring-border-active/50 border border-transparent - shadow-[0_0_33px_rgba(0,0,0,0.8)] - focus-within:ring-2 focus-within:ring-primary/40 focus-within:border-primary" - > - <div class="flex flex-wrap gap-1"> - <Show when={local.file.active()}> - <FileTag - default - file={local.file.active()!} - onClose={() => local.file.close(local.file.active()?.path ?? "")} - /> - </Show> - <For each={local.file.opened().filter((x) => x.selection)}> - {(file) => <FileTag file={file} onClose={() => local.file.select(file.path, undefined)} />} - </For> - </div> - <input - ref={(el) => (inputRef = el)} - type="text" - value={store.prompt} - onInput={(e) => setStore("prompt", e.currentTarget.value)} - placeholder="Placeholder text..." - class="w-full p-1 pb-4 text-text font-light placeholder-text-muted/70 text-sm focus:outline-none" - /> - <div class="flex justify-between items-center text-xs text-text-muted"> - <div class="flex gap-2 items-center"> - <Select - options={local.agent.list().map((a) => a.name)} - current={local.agent.current().name} - onSelect={local.agent.set} - class="uppercase" - /> - <Button onClick={() => setStore("modelSelectOpen", true)}> - {local.model.current()?.name ?? "Select model"} - <Icon name="chevron-down" size={24} class="text-text-muted" /> - </Button> - <span class="text-text-muted/70 whitespace-nowrap">{local.model.current()?.provider.name}</span> - </div> - <div class="flex gap-1 items-center"> - <IconButton class="text-text-muted" size="xs" variant="ghost"> - <Icon name="photo" size={16} /> - </IconButton> - <IconButton class="text-background-panel! bg-primary rounded-full!" size="xs" variant="ghost"> - <Icon name="arrow-up" size={14} /> - </IconButton> - </div> - </div> - </div> - </form> - </div> + </ResizeablePane> + </ResizeableLayout> <Show when={store.modelSelectOpen}> <SelectDialog key={(x) => `${x.provider.id}:${x.id}`} @@ -607,102 +276,3 @@ export default function Page() { </div> ) } - -const TabVisual = (props: { file: LocalFile }) => { - return ( - <div class="flex items-center gap-x-1.5"> - <FileIcon node={props.file} class="" /> - <span classList={{ "text-xs": true, "text-primary": !!props.file.status?.status, italic: !props.file.pinned }}> - {props.file.name} - </span> - <span class="text-xs 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 -}) => { - const sortable = createSortable(props.file.path) - - return ( - // @ts-ignore - <div use:sortable classList={{ "opacity-0": sortable.isActiveDraggable }}> - <Tooltip value={props.file.path} placement="bottom"> - <div class="relative"> - <Tabs.Trigger value={props.file.path} class="peer/tab pr-7" onClick={() => props.onTabClick(props.file)}> - <TabVisual file={props.file} /> - </Tabs.Trigger> - <IconButton - class="absolute right-1 top-1.5 opacity-0 text-text-muted/60 - peer-data-[selected]/tab:opacity-100 peer-data-[selected]/tab:text-text - peer-data-[selected]/tab:hover:bg-border-subtle - hover:opacity-100 peer-hover/tab:opacity-100" - size="xs" - variant="ghost" - onClick={() => props.onTabClose(props.file)} - > - <Icon name="close" size={16} /> - </IconButton> - </div> - </Tooltip> - </div> - ) -} - -const FileTag = (props: { file: LocalFile; default?: boolean; onClose: () => void }) => ( - <div - class="flex items-center bg-background group/tag - border border-border-subtle/60 border-dashed - rounded-md text-xs text-text-muted" - > - <IconButton class="text-text-muted" size="xs" variant="ghost" onClick={props.onClose}> - <Switch fallback={<FileIcon node={props.file} class="group-hover/tag:hidden size-3!" />}> - <Match when={props.default}> - <Icon name="file" class="group-hover/tag:hidden" size={12} /> - </Match> - </Switch> - <Icon name="close" class="hidden group-hover/tag:block" size={12} /> - </IconButton> - <div class="pr-1 flex gap-1 items-center"> - <span>{props.file.name}</span> - <Show when={!props.default && props.file.selection}> - <span class=""> - ({props.file.selection!.startLine}-{props.file.selection!.endLine}) - </span> - </Show> - </div> - </div> -) - -const ConstrainDragYAxis = () => { - 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: any) => { - addTransformer("draggables", event.draggable.id, transformer) - }) - onDragEnd((event: any) => { - removeTransformer("draggables", event.draggable.id, transformer.id) - }) - return <></> -} |
