Files
opencode/packages/desktop/src/pages/index.tsx
2025-10-14 07:15:08 -05:00

418 lines
16 KiB
TypeScript

import { FileIcon, Icon, IconButton, Tooltip } from "@/ui"
import * as KobalteTabs from "@kobalte/core/tabs"
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 { useSync, useSDK, useLocal } from "@/context"
import type { LocalFile, TextSelection } from "@/context/local"
import SessionList from "@/components/session-list"
import SessionTimeline from "@/components/session-timeline"
import PromptForm, { type PromptContentPart, type PromptSubmitValue } from "@/components/prompt-form"
import { createStore } from "solid-js/store"
import { getDirectory, getFilename } from "@/utils"
import { Select } from "@/components/select"
import { Tabs } from "@/ui/tabs"
import { Code } from "@/components/code"
export default function Page() {
const local = useLocal()
const sync = useSync()
const sdk = useSDK()
const [store, setStore] = createStore({
clickTimer: undefined as number | undefined,
modelSelectOpen: false,
fileSelectOpen: false,
})
let inputRef: HTMLTextAreaElement | 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()
// TODO: command palette
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") {
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 handlePromptSubmit = async (prompt: PromptSubmitValue) => {
const existingSession = local.session.active()
let session = existingSession
if (!session) {
const created = await sdk.session.create()
session = created.data ?? undefined
}
if (!session) return
local.session.setActive(session.id)
interface SubmissionAttachment {
path: string
selection?: TextSelection
label: string
}
const createAttachmentKey = (path: string, selection?: TextSelection) => {
if (!selection) return path
return `${path}:${selection.startLine}:${selection.startChar}:${selection.endLine}:${selection.endChar}`
}
const formatAttachmentLabel = (path: string, selection?: TextSelection) => {
if (!selection) return getFilename(path)
return `${getFilename(path)} (${selection.startLine}-${selection.endLine})`
}
const toAbsolutePath = (path: string) => (path.startsWith("/") ? path : sync.absolute(path))
const attachments = new Map<string, SubmissionAttachment>()
const registerAttachment = (path: string, selection: TextSelection | undefined, label?: string) => {
if (!path) return
const key = createAttachmentKey(path, selection)
if (attachments.has(key)) return
attachments.set(key, {
path,
selection,
label: label ?? formatAttachmentLabel(path, selection),
})
}
const promptAttachments = prompt.parts.filter(
(part): part is Extract<PromptContentPart, { kind: "attachment" }> => part.kind === "attachment",
)
for (const part of promptAttachments) {
registerAttachment(part.path, part.selection, part.display)
}
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 = Array.from(attachments.values()).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.label}`,
start: 0,
end: 0,
},
path: absolute,
},
}
})
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.text,
},
...attachmentParts,
],
},
})
}
return (
<div class="relative">
<div class="h-screen flex">
<div class="shrink-0 w-56">
<SessionList />
</div>
<div class="grow w-full min-w-0 overflow-y-auto flex justify-center">
<Show when={local.session.active()}>
{(activeSession) => <SessionTimeline session={activeSession().id} class="max-w-xl" />}
</Show>
</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 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>
</div>
<div class="hidden grow min-w-0">
<EditorPane onFileClick={handleFileClick} />
</div>
<div class="absolute bottom-4 right-4 border border-border-subtle/60 p-2 rounded-xl bg-background w-xl flex flex-col gap-2 z-50">
<div class="flex items-center gap-2">
<Select
options={sync.data.session}
current={local.session.active()}
placeholder="New Session"
value={(x) => x.id}
label={(x) => x.title}
onSelect={(s) => local.session.setActive(s?.id)}
class="bg-transparent! max-w-48 pl-0! text-text-muted!"
/>
<Show when={local.session.active()}>
<>
<div>/</div>
<Select
options={sync.data.message[local.session.active()!.id]?.filter((m) => m.role === "user") ?? []}
label={(m) => sync.data.part[m.id].find((p) => p.type === "text")!.text}
class="bg-transparent! max-w-48 pl-0! text-text-muted!"
/>
</>
</Show>
</div>
<div class="h-72 text-xs overflow-x-scroll no-scrollbar w-full min-w-0">
<Tabs
class="relative grow w-full flex flex-col gap-1 h-full"
value={local.context.activeFile()?.path}
onChange={local.context.setActiveFile}
>
<div class="sticky top-0 shrink-0 flex items-center gap-1">
<IconButton
class="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="secondary"
onClick={() => setStore("fileSelectOpen", true)}
>
<Icon name="plus" size={12} />
</IconButton>
<Tabs.List class="grow after:hidden! h-full divide-none! gap-1">
<For each={local.context.files()}>
{(file) => (
<KobalteTabs.Trigger
value={file.path}
class="h-full"
// onClick={() => props.onTabClick(props.file)}
>
<div class="flex items-center gap-x-1 rounded-md bg-background-panel px-2 h-full">
<FileIcon node={file} class="shrink-0 size-3!" />
<span class="text-xs text-text whitespace-nowrap">{getFilename(file.path)}</span>
</div>
</KobalteTabs.Trigger>
)}
</For>
</Tabs.List>
</div>
<For each={local.context.files()}>
{(file) => (
<Tabs.Content value={file.path} class="grow h-full pt-1 select-text rounded-md">
<Code path={file.path} code={file.content?.content ?? ""} />
</Tabs.Content>
)}
</For>
</Tabs>
</div>
<PromptForm
onSubmit={handlePromptSubmit}
onOpenModelSelect={() => setStore("modelSelectOpen", true)}
onInputRefChange={(element: HTMLTextAreaElement | undefined) => {
inputRef = element ?? undefined
}}
/>
<div class="hidden relative flex-1 min-h-0 overflow-y-auto overflow-x-hidden">
<Show when={local.session.active()}>
{(activeSession) => (
<div class="relative">
<div class="sticky top-0 bg-background z-50 px-2 h-8 border-b border-border-subtle/30">
<div class="h-full flex items-center gap-2">
<h2 class="text-sm font-medium text-text truncate">
{activeSession().title || "Untitled Session"}
</h2>
</div>
</div>
<SessionTimeline session={activeSession().id} />
</div>
)}
</Show>
</div>
</div>
</div>
<Show when={store.modelSelectOpen}>
<SelectDialog
key={(x) => `${x.provider.id}:${x.id}`}
items={local.model.list()}
current={local.model.current()}
render={(i) => (
<div class="w-full flex items-center justify-between">
<div class="flex items-center gap-x-2 text-text-muted grow min-w-0">
<img src={`https://models.dev/logos/${i.provider.id}.svg`} class="size-4 invert opacity-40" />
<span class="text-xs text-text whitespace-nowrap">{i.name}</span>
<span class="text-xs text-text-muted/80 whitespace-nowrap overflow-hidden overflow-ellipsis truncate min-w-0">
{i.id}
</span>
</div>
<div class="flex items-center gap-x-1 text-text-muted/40 shrink-0">
<Tooltip forceMount={false} value="Reasoning">
<Icon name="brain" size={16} classList={{ "text-accent": i.reasoning }} />
</Tooltip>
<Tooltip forceMount={false} value="Tools">
<Icon name="hammer" size={16} classList={{ "text-secondary": i.tool_call }} />
</Tooltip>
<Tooltip forceMount={false} value="Attachments">
<Icon name="photo" size={16} classList={{ "text-success": i.attachment }} />
</Tooltip>
<div class="rounded-full bg-text-muted/20 text-text-muted/80 w-9 h-4 flex items-center justify-center text-[10px]">
{new Intl.NumberFormat("en-US", {
notation: "compact",
compactDisplay: "short",
}).format(i.limit.context)}
</div>
<Tooltip forceMount={false} value={`$${i.cost?.input}/1M input, $${i.cost?.output}/1M output`}>
<div class="rounded-full bg-success/20 text-success/80 w-9 h-4 flex items-center justify-center text-[10px]">
<Switch fallback="FREE">
<Match when={i.cost?.input > 10}>$$$</Match>
<Match when={i.cost?.input > 1}>$$</Match>
<Match when={i.cost?.input > 0.1}>$</Match>
</Switch>
</div>
</Tooltip>
</div>
</div>
)}
filter={["provider.name", "name", "id"]}
groupBy={(x) => x.provider.name}
onClose={() => setStore("modelSelectOpen", false)}
onSelect={(x) => local.model.set(x ? { modelID: x.id, providerID: x.provider.id } : undefined)}
/>
</Show>
<Show when={store.fileSelectOpen}>
<SelectDialog
items={local.file.search}
key={(x) => x}
render={(i) => (
<div class="w-full flex items-center justify-between">
<div class="flex items-center gap-x-2 text-text-muted grow min-w-0">
<FileIcon node={{ path: i, type: "file" }} class="shrink-0 size-4" />
<span class="text-xs text-text whitespace-nowrap">{getFilename(i)}</span>
<span class="text-xs text-text-muted/80 whitespace-nowrap overflow-hidden overflow-ellipsis truncate min-w-0">
{getDirectory(i)}
</span>
</div>
<div class="flex items-center gap-x-1 text-text-muted/40 shrink-0"></div>
</div>
)}
onClose={() => setStore("fileSelectOpen", false)}
onSelect={(x) => (x ? local.context.openFile(x) : undefined)}
// onSelect={(x) => (x ? local.file.open(x, { pinned: true }) : undefined)}
/>
</Show>
</div>
)
}