|
|
@@ -1,6 +1,5 @@
|
|
|
-import { For, Show, createEffect, createMemo, on } from "solid-js"
|
|
|
+import { For, Show, createEffect, createMemo, on, onCleanup } from "solid-js"
|
|
|
import { createStore } from "solid-js/store"
|
|
|
-import { createMediaQuery } from "@solid-primitives/media"
|
|
|
import { useParams } from "@solidjs/router"
|
|
|
import { Tabs } from "@opencode-ai/ui/tabs"
|
|
|
import { ResizeHandle } from "@opencode-ai/ui/resize-handle"
|
|
|
@@ -17,7 +16,7 @@ import { useLanguage } from "@/context/language"
|
|
|
import { useLayout } from "@/context/layout"
|
|
|
import { useTerminal, type LocalPTY } from "@/context/terminal"
|
|
|
import { terminalTabLabel } from "@/pages/session/terminal-label"
|
|
|
-import { createPresence, createSizing, focusTerminalById } from "@/pages/session/helpers"
|
|
|
+import { createSizing, focusTerminalById } from "@/pages/session/helpers"
|
|
|
import { getTerminalHandoff, setTerminalHandoff } from "@/pages/session/handoff"
|
|
|
|
|
|
export function TerminalPanel() {
|
|
|
@@ -27,13 +26,10 @@ export function TerminalPanel() {
|
|
|
const language = useLanguage()
|
|
|
const command = useCommand()
|
|
|
|
|
|
- const isDesktop = createMediaQuery("(min-width: 768px)")
|
|
|
const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`)
|
|
|
const view = createMemo(() => layout.view(sessionKey))
|
|
|
|
|
|
const opened = createMemo(() => view().terminal.opened())
|
|
|
- const open = createMemo(() => isDesktop() && opened())
|
|
|
- const panel = createPresence(open)
|
|
|
const size = createSizing()
|
|
|
const height = createMemo(() => layout.terminal.height())
|
|
|
const close = () => view().terminal.close()
|
|
|
@@ -42,6 +38,25 @@ export function TerminalPanel() {
|
|
|
const [store, setStore] = createStore({
|
|
|
autoCreated: false,
|
|
|
activeDraggable: undefined as string | undefined,
|
|
|
+ view: typeof window === "undefined" ? 1000 : (window.visualViewport?.height ?? window.innerHeight),
|
|
|
+ })
|
|
|
+
|
|
|
+ const max = () => store.view * 0.6
|
|
|
+ const pane = () => Math.min(height(), max())
|
|
|
+
|
|
|
+ createEffect(() => {
|
|
|
+ if (typeof window === "undefined") return
|
|
|
+
|
|
|
+ const sync = () => setStore("view", window.visualViewport?.height ?? window.innerHeight)
|
|
|
+ const port = window.visualViewport
|
|
|
+
|
|
|
+ sync()
|
|
|
+ window.addEventListener("resize", sync)
|
|
|
+ port?.addEventListener("resize", sync)
|
|
|
+ onCleanup(() => {
|
|
|
+ window.removeEventListener("resize", sync)
|
|
|
+ port?.removeEventListener("resize", sync)
|
|
|
+ })
|
|
|
})
|
|
|
|
|
|
createEffect(() => {
|
|
|
@@ -66,21 +81,42 @@ export function TerminalPanel() {
|
|
|
),
|
|
|
)
|
|
|
|
|
|
+ const focus = (id: string) => {
|
|
|
+ focusTerminalById(id)
|
|
|
+
|
|
|
+ const frame = requestAnimationFrame(() => {
|
|
|
+ if (!opened()) return
|
|
|
+ if (terminal.active() !== id) return
|
|
|
+ focusTerminalById(id)
|
|
|
+ })
|
|
|
+
|
|
|
+ const timers = [120, 240].map((ms) =>
|
|
|
+ window.setTimeout(() => {
|
|
|
+ if (!opened()) return
|
|
|
+ if (terminal.active() !== id) return
|
|
|
+ focusTerminalById(id)
|
|
|
+ }, ms),
|
|
|
+ )
|
|
|
+
|
|
|
+ return () => {
|
|
|
+ cancelAnimationFrame(frame)
|
|
|
+ for (const timer of timers) clearTimeout(timer)
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
createEffect(
|
|
|
on(
|
|
|
- () => terminal.active(),
|
|
|
- (activeId) => {
|
|
|
- if (!activeId || !panel.open()) return
|
|
|
- if (document.activeElement instanceof HTMLElement) {
|
|
|
- document.activeElement.blur()
|
|
|
- }
|
|
|
- setTimeout(() => focusTerminalById(activeId), 0)
|
|
|
+ () => [opened(), terminal.active()] as const,
|
|
|
+ ([next, id]) => {
|
|
|
+ if (!next || !id) return
|
|
|
+ const stop = focus(id)
|
|
|
+ onCleanup(stop)
|
|
|
},
|
|
|
),
|
|
|
)
|
|
|
|
|
|
createEffect(() => {
|
|
|
- if (panel.open()) return
|
|
|
+ if (opened()) return
|
|
|
const active = document.activeElement
|
|
|
if (!(active instanceof HTMLElement)) return
|
|
|
if (!root?.contains(active)) return
|
|
|
@@ -138,150 +174,156 @@ export function TerminalPanel() {
|
|
|
|
|
|
const activeId = terminal.active()
|
|
|
if (!activeId) return
|
|
|
- setTimeout(() => {
|
|
|
+ requestAnimationFrame(() => {
|
|
|
+ if (terminal.active() !== activeId) return
|
|
|
focusTerminalById(activeId)
|
|
|
- }, 0)
|
|
|
+ })
|
|
|
}
|
|
|
|
|
|
return (
|
|
|
- <Show when={panel.show()}>
|
|
|
+ <div
|
|
|
+ ref={root}
|
|
|
+ id="terminal-panel"
|
|
|
+ role="region"
|
|
|
+ aria-label={language.t("terminal.title")}
|
|
|
+ aria-hidden={!opened()}
|
|
|
+ inert={!opened()}
|
|
|
+ class="relative w-full shrink-0 overflow-hidden bg-background-stronger"
|
|
|
+ classList={{
|
|
|
+ "border-t border-border-weak-base": opened(),
|
|
|
+ "transition-[height] duration-200 ease-[cubic-bezier(0.22,1,0.36,1)] will-change-[height] motion-reduce:transition-none":
|
|
|
+ !size.active(),
|
|
|
+ }}
|
|
|
+ style={{ height: opened() ? `${pane()}px` : "0px" }}
|
|
|
+ >
|
|
|
<div
|
|
|
- ref={root}
|
|
|
- id="terminal-panel"
|
|
|
- role="region"
|
|
|
- aria-label={language.t("terminal.title")}
|
|
|
- aria-hidden={!panel.open()}
|
|
|
- inert={!panel.open()}
|
|
|
- class="relative w-full shrink-0 overflow-hidden"
|
|
|
+ class="absolute inset-x-0 top-0 flex flex-col"
|
|
|
classList={{
|
|
|
- "opacity-100": panel.open(),
|
|
|
- "opacity-0 pointer-events-none": !panel.open(),
|
|
|
- "transition-[height,opacity] duration-200 ease-[cubic-bezier(0.22,1,0.36,1)] will-change-[height] motion-reduce:transition-none":
|
|
|
+ "translate-y-0": opened(),
|
|
|
+ "translate-y-full pointer-events-none": !opened(),
|
|
|
+ "transition-transform duration-200 ease-[cubic-bezier(0.22,1,0.36,1)] will-change-transform motion-reduce:transition-none":
|
|
|
!size.active(),
|
|
|
}}
|
|
|
- style={{ height: panel.open() ? `${height()}px` : "0px" }}
|
|
|
+ style={{ height: `${pane()}px` }}
|
|
|
>
|
|
|
- <div class="size-full flex flex-col border-t border-border-weak-base">
|
|
|
- <div onPointerDown={() => size.start()}>
|
|
|
- <ResizeHandle
|
|
|
- direction="vertical"
|
|
|
- size={height()}
|
|
|
- min={100}
|
|
|
- max={typeof window === "undefined" ? 1000 : window.innerHeight * 0.6}
|
|
|
- collapseThreshold={50}
|
|
|
- onResize={(next) => {
|
|
|
- size.touch()
|
|
|
- layout.terminal.resize(next)
|
|
|
- }}
|
|
|
- onCollapse={close}
|
|
|
- />
|
|
|
- </div>
|
|
|
- <Show
|
|
|
- when={terminal.ready()}
|
|
|
- fallback={
|
|
|
- <div class="flex flex-col h-full pointer-events-none">
|
|
|
- <div class="h-10 flex items-center gap-2 px-2 border-b border-border-weaker-base bg-background-stronger overflow-hidden">
|
|
|
- <For each={handoff()}>
|
|
|
- {(title) => (
|
|
|
- <div class="px-2 py-1 rounded-md bg-surface-base text-14-regular text-text-weak truncate max-w-40">
|
|
|
- {title}
|
|
|
- </div>
|
|
|
- )}
|
|
|
- </For>
|
|
|
- <div class="flex-1" />
|
|
|
- <div class="text-text-weak pr-2">
|
|
|
- {language.t("common.loading")}
|
|
|
- {language.t("common.loading.ellipsis")}
|
|
|
- </div>
|
|
|
- </div>
|
|
|
- <div class="flex-1 flex items-center justify-center text-text-weak">
|
|
|
- {language.t("terminal.loading")}
|
|
|
- </div>
|
|
|
- </div>
|
|
|
- }
|
|
|
- >
|
|
|
- <DragDropProvider
|
|
|
- onDragStart={handleTerminalDragStart}
|
|
|
- onDragEnd={handleTerminalDragEnd}
|
|
|
- onDragOver={handleTerminalDragOver}
|
|
|
- collisionDetector={closestCenter}
|
|
|
- >
|
|
|
- <DragDropSensors />
|
|
|
- <ConstrainDragYAxis />
|
|
|
- <div class="flex flex-col h-full">
|
|
|
- <Tabs
|
|
|
- variant="alt"
|
|
|
- value={terminal.active()}
|
|
|
- onChange={(id) => terminal.open(id)}
|
|
|
- class="!h-auto !flex-none"
|
|
|
- >
|
|
|
- <Tabs.List class="h-10 border-b border-border-weaker-base">
|
|
|
- <SortableProvider ids={ids()}>
|
|
|
- <For each={ids()}>
|
|
|
- {(id) => (
|
|
|
- <Show when={byId().get(id)}>
|
|
|
- {(pty) => <SortableTerminalTab terminal={pty()} onClose={close} />}
|
|
|
- </Show>
|
|
|
- )}
|
|
|
- </For>
|
|
|
- </SortableProvider>
|
|
|
- <div class="h-full flex items-center justify-center">
|
|
|
- <TooltipKeybind
|
|
|
- title={language.t("command.terminal.new")}
|
|
|
- keybind={command.keybind("terminal.new")}
|
|
|
- class="flex items-center"
|
|
|
- >
|
|
|
- <IconButton
|
|
|
- icon="plus-small"
|
|
|
- variant="ghost"
|
|
|
- iconSize="large"
|
|
|
- onClick={terminal.new}
|
|
|
- aria-label={language.t("command.terminal.new")}
|
|
|
- />
|
|
|
- </TooltipKeybind>
|
|
|
+ <div class="hidden md:block" onPointerDown={() => size.start()}>
|
|
|
+ <ResizeHandle
|
|
|
+ direction="vertical"
|
|
|
+ size={pane()}
|
|
|
+ min={100}
|
|
|
+ max={max()}
|
|
|
+ collapseThreshold={50}
|
|
|
+ onResize={(next) => {
|
|
|
+ size.touch()
|
|
|
+ layout.terminal.resize(next)
|
|
|
+ }}
|
|
|
+ onCollapse={close}
|
|
|
+ />
|
|
|
+ </div>
|
|
|
+ <Show
|
|
|
+ when={terminal.ready()}
|
|
|
+ fallback={
|
|
|
+ <div class="flex flex-col h-full pointer-events-none">
|
|
|
+ <div class="h-10 flex items-center gap-2 px-2 border-b border-border-weaker-base bg-background-stronger overflow-hidden">
|
|
|
+ <For each={handoff()}>
|
|
|
+ {(title) => (
|
|
|
+ <div class="px-2 py-1 rounded-md bg-surface-base text-14-regular text-text-weak truncate max-w-40">
|
|
|
+ {title}
|
|
|
</div>
|
|
|
- </Tabs.List>
|
|
|
- </Tabs>
|
|
|
- <div class="flex-1 min-h-0 relative">
|
|
|
- <Show when={terminal.active()} keyed>
|
|
|
- {(id) => (
|
|
|
- <Show when={byId().get(id)}>
|
|
|
- {(pty) => (
|
|
|
- <div id={`terminal-wrapper-${id}`} class="absolute inset-0">
|
|
|
- <Terminal
|
|
|
- pty={pty()}
|
|
|
- onConnect={() => terminal.trim(id)}
|
|
|
- onCleanup={terminal.update}
|
|
|
- onConnectError={() => terminal.clone(id)}
|
|
|
- />
|
|
|
- </div>
|
|
|
- )}
|
|
|
- </Show>
|
|
|
- )}
|
|
|
- </Show>
|
|
|
+ )}
|
|
|
+ </For>
|
|
|
+ <div class="flex-1" />
|
|
|
+ <div class="text-text-weak pr-2">
|
|
|
+ {language.t("common.loading")}
|
|
|
+ {language.t("common.loading.ellipsis")}
|
|
|
</div>
|
|
|
</div>
|
|
|
- <DragOverlay>
|
|
|
- <Show when={store.activeDraggable}>
|
|
|
- {(draggedId) => (
|
|
|
- <Show when={byId().get(draggedId())}>
|
|
|
- {(t) => (
|
|
|
- <div class="relative p-1 h-10 flex items-center bg-background-stronger text-14-regular">
|
|
|
- {terminalTabLabel({
|
|
|
- title: t().title,
|
|
|
- titleNumber: t().titleNumber,
|
|
|
- t: language.t as (key: string, vars?: Record<string, string | number | boolean>) => string,
|
|
|
- })}
|
|
|
+ <div class="flex-1 flex items-center justify-center text-text-weak">{language.t("terminal.loading")}</div>
|
|
|
+ </div>
|
|
|
+ }
|
|
|
+ >
|
|
|
+ <DragDropProvider
|
|
|
+ onDragStart={handleTerminalDragStart}
|
|
|
+ onDragEnd={handleTerminalDragEnd}
|
|
|
+ onDragOver={handleTerminalDragOver}
|
|
|
+ collisionDetector={closestCenter}
|
|
|
+ >
|
|
|
+ <DragDropSensors />
|
|
|
+ <ConstrainDragYAxis />
|
|
|
+ <div class="flex flex-col h-full">
|
|
|
+ <Tabs
|
|
|
+ variant="alt"
|
|
|
+ value={terminal.active()}
|
|
|
+ onChange={(id) => terminal.open(id)}
|
|
|
+ class="!h-auto !flex-none"
|
|
|
+ >
|
|
|
+ <Tabs.List class="h-10 border-b border-border-weaker-base">
|
|
|
+ <SortableProvider ids={ids()}>
|
|
|
+ <For each={ids()}>
|
|
|
+ {(id) => (
|
|
|
+ <Show when={byId().get(id)}>
|
|
|
+ {(pty) => <SortableTerminalTab terminal={pty()} onClose={close} />}
|
|
|
+ </Show>
|
|
|
+ )}
|
|
|
+ </For>
|
|
|
+ </SortableProvider>
|
|
|
+ <div class="h-full flex items-center justify-center">
|
|
|
+ <TooltipKeybind
|
|
|
+ title={language.t("command.terminal.new")}
|
|
|
+ keybind={command.keybind("terminal.new")}
|
|
|
+ class="flex items-center"
|
|
|
+ >
|
|
|
+ <IconButton
|
|
|
+ icon="plus-small"
|
|
|
+ variant="ghost"
|
|
|
+ iconSize="large"
|
|
|
+ onClick={terminal.new}
|
|
|
+ aria-label={language.t("command.terminal.new")}
|
|
|
+ />
|
|
|
+ </TooltipKeybind>
|
|
|
+ </div>
|
|
|
+ </Tabs.List>
|
|
|
+ </Tabs>
|
|
|
+ <div class="flex-1 min-h-0 relative">
|
|
|
+ <Show when={terminal.active()} keyed>
|
|
|
+ {(id) => (
|
|
|
+ <Show when={byId().get(id)}>
|
|
|
+ {(pty) => (
|
|
|
+ <div id={`terminal-wrapper-${id}`} class="absolute inset-0">
|
|
|
+ <Terminal
|
|
|
+ pty={pty()}
|
|
|
+ autoFocus={opened()}
|
|
|
+ onConnect={() => terminal.trim(id)}
|
|
|
+ onCleanup={terminal.update}
|
|
|
+ onConnectError={() => terminal.clone(id)}
|
|
|
+ />
|
|
|
</div>
|
|
|
)}
|
|
|
</Show>
|
|
|
)}
|
|
|
</Show>
|
|
|
- </DragOverlay>
|
|
|
- </DragDropProvider>
|
|
|
- </Show>
|
|
|
- </div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ <DragOverlay>
|
|
|
+ <Show when={store.activeDraggable}>
|
|
|
+ {(draggedId) => (
|
|
|
+ <Show when={byId().get(draggedId())}>
|
|
|
+ {(t) => (
|
|
|
+ <div class="relative p-1 h-10 flex items-center bg-background-stronger text-14-regular">
|
|
|
+ {terminalTabLabel({
|
|
|
+ title: t().title,
|
|
|
+ titleNumber: t().titleNumber,
|
|
|
+ t: language.t as (key: string, vars?: Record<string, string | number | boolean>) => string,
|
|
|
+ })}
|
|
|
+ </div>
|
|
|
+ )}
|
|
|
+ </Show>
|
|
|
+ )}
|
|
|
+ </Show>
|
|
|
+ </DragOverlay>
|
|
|
+ </DragDropProvider>
|
|
|
+ </Show>
|
|
|
</div>
|
|
|
- </Show>
|
|
|
+ </div>
|
|
|
)
|
|
|
}
|