Files
opencode/packages/app/src/pages/index.tsx
2025-09-19 07:18:38 -05:00

618 lines
23 KiB
TypeScript

import { FileIcon, Icon, IconButton, Logo, Tooltip } from "@/ui"
import { Tabs } from "@/ui/tabs"
import FileTree from "@/components/file-tree"
import { createSignal, For, Match, onCleanup, onMount, Show, Switch } from "solid-js"
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 type { LocalFile } from "@/context/local"
import SessionList from "@/components/session-list"
import SessionTimeline from "@/components/session-timeline"
export default function Page() {
const sdk = useSDK()
const local = useLocal()
const [clickTimer, setClickTimer] = createSignal<number | undefined>()
const [activeItem, setActiveItem] = createSignal<string | undefined>(undefined)
const [inputValue, setInputValue] = createSignal("")
const [isDragging, setIsDragging] = createSignal<"left" | "right" | undefined>(undefined)
const [leftScrolled, setLeftScrolled] = createSignal(false)
// TODO: remove
local.model.set({ providerID: "opencode", modelID: "grok-code" })
let inputRef: HTMLInputElement | 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 = (e: KeyboardEvent) => {
const inputFocused = document.activeElement === inputRef
if (inputFocused) {
if (e.key === "Escape") {
inputRef?.blur()
}
return
}
if (local.file.active()) {
if (e.getModifierState(MOD)) {
if (e.key.toLowerCase() === "a") {
return
}
if (e.key.toLowerCase() === "c") {
return
}
}
}
if (e.key.length === 1 && e.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 (!clickTimer()) return
clearTimeout(clickTimer())
setClickTimer(undefined)
}
const startClickTimer = () => {
const newClickTimer = setTimeout(() => {
setClickTimer(undefined)
}, 300)
setClickTimer(newClickTimer as unknown as number)
}
const handleFileClick = async (file: LocalFile) => {
if (clickTimer()) {
resetClickTimer()
local.file.update(file.path, { ...file, pinned: true })
} else {
local.file.open(file.path)
startClickTimer()
}
}
const handleTabChange = (path: string) => {
local.file.open(path)
}
const handleTabClose = (file: LocalFile) => {
local.file.close(file.path)
}
const onDragStart = (event: any) => {
setActiveItem(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 = () => {
setActiveItem(undefined)
}
const handleLeftDragStart = (e: MouseEvent) => {
e.preventDefault()
setIsDragging("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 = () => {
setIsDragging(undefined)
document.removeEventListener("mousemove", handleMouseMove)
document.removeEventListener("mouseup", handleMouseUp)
}
document.addEventListener("mousemove", handleMouseMove)
document.addEventListener("mouseup", handleMouseUp)
}
const handleRightDragStart = (e: MouseEvent) => {
e.preventDefault()
setIsDragging("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 = () => {
setIsDragging(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 = inputValue()
setInputValue("")
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: local.model.current(),
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"
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">
Files
</Tabs.Trigger>
<Tabs.Trigger value="changes" class="flex-1 justify-center">
Changes
</Tabs.Trigger>
</Tabs.List>
</div>
<Tabs.Content
value="files"
class="grow min-h-0 py-2 bg-background"
onScroll={(e: Event & { currentTarget: HTMLDivElement }) => setLeftScrolled(e.currentTarget.scrollTop > 0)}
>
<FileTree path="" onFileClick={handleFileClick} />
<Show when={leftScrolled()}>
<div
class="pointer-events-none sticky top-20 left-px h-4
bg-gradient-to-t from-transparent to-background"
style={`width: ${local.layout.leftWidth() - 2}px`}
/>
</Show>
<div
class="pointer-events-none fixed bottom-0 left-px h-4
bg-gradient-to-b from-transparent to-background"
style={`width: ${local.layout.leftWidth() - 2}px`}
/>
</Tabs.Content>
<Tabs.Content value="changes" class="grow min-h-0 py-2 bg-background">
<div class="px-2 text-sm text-text-muted">No changes yet</div>
</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)}
>
<div
class="w-0.5 h-full bg-transparent group-hover:bg-border-active transition-colors"
classList={{
"bg-border-active": isDragging() === "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"
style={`width: ${local.layout.rightWidth()}px`}
>
<div class="relative flex-1 min-h-0 overflow-y-auto no-scrollbar">
<Show when={local.session.active()} fallback={<SessionList />}>
{(activeSession) => (
<div class="relative">
<div class="sticky top-0 bg-background z-50 p-2 h-9 border-b border-border-subtle/30">
<div class="flex items-center gap-2">
<IconButton
size="xs"
variant="ghost"
onClick={() => local.session.clearActive()}
class="text-text-muted hover:text-text"
>
<Icon name="arrow-left" size={14} />
</IconButton>
<h2 class="text-sm font-medium text-text truncate">
{activeSession().title || "Untitled Session"}
</h2>
</div>
</div>
<SessionTimeline session={activeSession().id} />
</div>
)}
</Show>
<div
class="pointer-events-none fixed top-0 right-px h-4
bg-gradient-to-t from-transparent to-background"
style={`width: ${local.layout.rightWidth() - 2}px`}
/>
<div
class="pointer-events-none fixed bottom-0 right-px h-4
bg-gradient-to-b from-transparent to-background"
style={`width: ${local.layout.rightWidth() - 2}px`}
/>
</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
class="w-0.5 h-full bg-transparent group-hover:bg-border-active transition-colors"
classList={{ "bg-border-active": isDragging() === "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>
{activeItem() &&
(() => {
const draggedFile = local.file.node(activeItem()!)
return (
<div
class="relative px-3 h-9 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={inputValue()}
onInput={(e) => setInputValue(e.currentTarget.value)}
placeholder="It all starts with a prompt..."
class="w-full p-1 pb-4 text-text font-light placeholder-text-muted/70 text-sm focus:outline-none"
/>
<div class="px-1 flex justify-between items-center text-xs text-text-muted">
<span>
<span class="text-primary uppercase">{local.agent.current()?.name ?? "unknown"}</span> /{" "}
{local.model.parsed().provider} / {local.model.parsed().model}
</span>
<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>
</div>
)
}
const TabVisual = (props: { file: LocalFile }) => {
const local = useLocal()
return (
<div class="flex items-center gap-x-1.5">
<FileIcon node={props.file} class="" />
<span
classList={{ "text-xs": true, "text-primary": local.file.changed(props.file.path), italic: !props.file.pinned }}
>
{props.file.name}
</span>
<span class="text-xs opacity-70">
<Switch>
<Match when={local.file.status(props.file.path)?.status === "modified"}>
<span class="text-primary">M</span>
</Match>
<Match when={local.file.status(props.file.path)?.status === "added"}>
<span class="text-success">A</span>
</Match>
<Match when={local.file.status(props.file.path)?.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-2 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 <></>
}