summaryrefslogtreecommitdiffhomepage
path: root/packages/app/src/pages
diff options
context:
space:
mode:
Diffstat (limited to 'packages/app/src/pages')
-rw-r--r--packages/app/src/pages/index.tsx636
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 <></>
-}