summaryrefslogtreecommitdiffhomepage
path: root/packages/desktop/src/pages
diff options
context:
space:
mode:
authorAdam <[email protected]>2025-10-03 09:04:28 -0500
committerAdam <[email protected]>2025-10-03 09:04:28 -0500
commit3fa280d21878ae391674a21758199df3d2d8c3b5 (patch)
treef70c6ecafffeecc8e7a59dc9acef66c59a9ea54a /packages/desktop/src/pages
parent1d58b5548287a3e32ffce3abdcf0f2db08fdb155 (diff)
downloadopencode-3fa280d21878ae391674a21758199df3d2d8c3b5.tar.gz
opencode-3fa280d21878ae391674a21758199df3d2d8c3b5.zip
chore: app -> desktop
Diffstat (limited to 'packages/desktop/src/pages')
-rw-r--r--packages/desktop/src/pages/index.tsx278
-rw-r--r--packages/desktop/src/pages/layout.tsx5
2 files changed, 283 insertions, 0 deletions
diff --git a/packages/desktop/src/pages/index.tsx b/packages/desktop/src/pages/index.tsx
new file mode 100644
index 000000000..128c6adfd
--- /dev/null
+++ b/packages/desktop/src/pages/index.tsx
@@ -0,0 +1,278 @@
+import { FileIcon, Icon, IconButton, Tooltip } from "@/ui"
+import { Tabs } from "@/ui/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 { 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"
+import { createStore } from "solid-js/store"
+import { getDirectory, getFilename } from "@/utils"
+
+export default function Page() {
+ const local = useLocal()
+ const [store, setStore] = createStore({
+ clickTimer: undefined as number | undefined,
+ modelSelectOpen: false,
+ fileSelectOpen: false,
+ })
+
+ 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"
+
+ 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()
+ }
+ }
+
+ return (
+ <div class="relative">
+ <ResizeableLayout
+ id={layoutKey}
+ defaults={{
+ explorer: { size: 24, visible: true },
+ editor: { size: 56, visible: true },
+ timeline: { size: 20, visible: false },
+ }}
+ class="h-screen"
+ >
+ <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 />}>
+ {(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">
+ <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>
+ </ResizeablePane>
+ </ResizeableLayout>
+ <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.file.open(x, { pinned: true }) : undefined)}
+ />
+ </Show>
+ </div>
+ )
+}
diff --git a/packages/desktop/src/pages/layout.tsx b/packages/desktop/src/pages/layout.tsx
new file mode 100644
index 000000000..d2e33a2ca
--- /dev/null
+++ b/packages/desktop/src/pages/layout.tsx
@@ -0,0 +1,5 @@
+import { type ParentProps } from "solid-js"
+
+export default function Layout(props: ParentProps) {
+ return <main class="">{props.children}</main>
+}