mirror of
https://gitea.toothfairyai.com/ToothFairyAI/tf_code.git
synced 2026-04-19 15:14:49 +00:00
327 lines
11 KiB
TypeScript
327 lines
11 KiB
TypeScript
import { For, Show, createEffect, createMemo, on, onCleanup } from "solid-js"
|
|
import { createStore } from "solid-js/store"
|
|
import { useParams } from "@solidjs/router"
|
|
import { Tabs } from "@opencode-ai/ui/tabs"
|
|
import { ResizeHandle } from "@opencode-ai/ui/resize-handle"
|
|
import { IconButton } from "@opencode-ai/ui/icon-button"
|
|
import { TooltipKeybind } from "@opencode-ai/ui/tooltip"
|
|
import { DragDropProvider, DragDropSensors, DragOverlay, SortableProvider, closestCenter } from "@thisbeyond/solid-dnd"
|
|
import type { DragEvent } from "@thisbeyond/solid-dnd"
|
|
import { ConstrainDragYAxis, getDraggableId } from "@/utils/solid-dnd"
|
|
|
|
import { SortableTerminalTab } from "@/components/session"
|
|
import { Terminal } from "@/components/terminal"
|
|
import { useCommand } from "@/context/command"
|
|
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 { createSizing, focusTerminalById } from "@/pages/session/helpers"
|
|
import { getTerminalHandoff, setTerminalHandoff } from "@/pages/session/handoff"
|
|
|
|
export function TerminalPanel() {
|
|
const params = useParams()
|
|
const layout = useLayout()
|
|
const terminal = useTerminal()
|
|
const language = useLanguage()
|
|
const command = useCommand()
|
|
|
|
const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`)
|
|
const view = createMemo(() => layout.view(sessionKey))
|
|
|
|
const opened = createMemo(() => view().terminal.opened())
|
|
const size = createSizing()
|
|
const height = createMemo(() => layout.terminal.height())
|
|
const close = () => view().terminal.close()
|
|
let root: HTMLDivElement | undefined
|
|
|
|
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(() => {
|
|
if (!opened()) {
|
|
setStore("autoCreated", false)
|
|
return
|
|
}
|
|
|
|
if (!terminal.ready() || terminal.all().length !== 0 || store.autoCreated) return
|
|
terminal.new()
|
|
setStore("autoCreated", true)
|
|
})
|
|
|
|
createEffect(
|
|
on(
|
|
() => terminal.all().length,
|
|
(count, prevCount) => {
|
|
if (prevCount === undefined || prevCount <= 0 || count !== 0) return
|
|
if (!opened()) return
|
|
close()
|
|
},
|
|
),
|
|
)
|
|
|
|
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(
|
|
() => [opened(), terminal.active()] as const,
|
|
([next, id]) => {
|
|
if (!next || !id) return
|
|
const stop = focus(id)
|
|
onCleanup(stop)
|
|
},
|
|
),
|
|
)
|
|
|
|
createEffect(() => {
|
|
if (opened()) return
|
|
const active = document.activeElement
|
|
if (!(active instanceof HTMLElement)) return
|
|
if (!root?.contains(active)) return
|
|
active.blur()
|
|
})
|
|
|
|
createEffect(() => {
|
|
const dir = params.dir
|
|
if (!dir) return
|
|
if (!terminal.ready()) return
|
|
language.locale()
|
|
|
|
setTerminalHandoff(
|
|
dir,
|
|
terminal.all().map((pty) =>
|
|
terminalTabLabel({
|
|
title: pty.title,
|
|
titleNumber: pty.titleNumber,
|
|
t: language.t as (key: string, vars?: Record<string, string | number | boolean>) => string,
|
|
}),
|
|
),
|
|
)
|
|
})
|
|
|
|
const handoff = createMemo(() => {
|
|
const dir = params.dir
|
|
if (!dir) return []
|
|
return getTerminalHandoff(dir) ?? []
|
|
})
|
|
|
|
const all = createMemo(() => terminal.all())
|
|
const ids = createMemo(() => all().map((pty) => pty.id))
|
|
const byId = createMemo(() => new Map(all().map((pty) => [pty.id, { ...pty }])))
|
|
|
|
const handleTerminalDragStart = (event: unknown) => {
|
|
const id = getDraggableId(event)
|
|
if (!id) return
|
|
setStore("activeDraggable", id)
|
|
}
|
|
|
|
const handleTerminalDragOver = (event: DragEvent) => {
|
|
const { draggable, droppable } = event
|
|
if (!draggable || !droppable) return
|
|
|
|
const terminals = terminal.all()
|
|
const fromIndex = terminals.findIndex((t: LocalPTY) => t.id === draggable.id.toString())
|
|
const toIndex = terminals.findIndex((t: LocalPTY) => t.id === droppable.id.toString())
|
|
if (fromIndex !== -1 && toIndex !== -1 && fromIndex !== toIndex) {
|
|
terminal.move(draggable.id.toString(), toIndex)
|
|
}
|
|
}
|
|
|
|
const handleTerminalDragEnd = () => {
|
|
setStore("activeDraggable", undefined)
|
|
|
|
const activeId = terminal.active()
|
|
if (!activeId) return
|
|
requestAnimationFrame(() => {
|
|
if (terminal.active() !== activeId) return
|
|
focusTerminalById(activeId)
|
|
})
|
|
}
|
|
|
|
return (
|
|
<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
|
|
class="absolute inset-x-0 top-0 flex flex-col"
|
|
classList={{
|
|
"pointer-events-none": !opened(),
|
|
}}
|
|
style={{ height: `${pane()}px` }}
|
|
>
|
|
<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>
|
|
)}
|
|
</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>
|
|
</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>
|
|
</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>
|
|
</div>
|
|
)
|
|
}
|