|
|
@@ -1,4 +1,16 @@
|
|
|
-import { For, onCleanup, onMount, Show, Match, Switch, createMemo, createEffect, on, createSignal } from "solid-js"
|
|
|
+import {
|
|
|
+ For,
|
|
|
+ Index,
|
|
|
+ onCleanup,
|
|
|
+ onMount,
|
|
|
+ Show,
|
|
|
+ Match,
|
|
|
+ Switch,
|
|
|
+ createMemo,
|
|
|
+ createEffect,
|
|
|
+ on,
|
|
|
+ createSignal,
|
|
|
+} from "solid-js"
|
|
|
import { createMediaQuery } from "@solid-primitives/media"
|
|
|
import { createResizeObserver } from "@solid-primitives/resize-observer"
|
|
|
import { Dynamic } from "solid-js/web"
|
|
|
@@ -350,14 +362,7 @@ export default function Page() {
|
|
|
|
|
|
const current = activeMessage()
|
|
|
const currentIndex = current ? msgs.findIndex((m) => m.id === current.id) : -1
|
|
|
-
|
|
|
- let targetIndex: number
|
|
|
- if (currentIndex === -1) {
|
|
|
- targetIndex = offset > 0 ? 0 : msgs.length - 1
|
|
|
- } else {
|
|
|
- targetIndex = currentIndex + offset
|
|
|
- }
|
|
|
-
|
|
|
+ const targetIndex = currentIndex === -1 ? (offset > 0 ? 0 : msgs.length - 1) : currentIndex + offset
|
|
|
if (targetIndex < 0 || targetIndex >= msgs.length) return
|
|
|
|
|
|
scrollToMessage(msgs[targetIndex], "auto")
|
|
|
@@ -381,11 +386,16 @@ export default function Page() {
|
|
|
sync.session.sync(params.id)
|
|
|
})
|
|
|
|
|
|
+ const [autoCreated, setAutoCreated] = createSignal(false)
|
|
|
+
|
|
|
createEffect(() => {
|
|
|
- if (!view().terminal.opened()) return
|
|
|
- if (!terminal.ready()) return
|
|
|
- if (terminal.all().length !== 0) return
|
|
|
+ if (!view().terminal.opened()) {
|
|
|
+ setAutoCreated(false)
|
|
|
+ return
|
|
|
+ }
|
|
|
+ if (!terminal.ready() || terminal.all().length !== 0 || autoCreated()) return
|
|
|
terminal.new()
|
|
|
+ setAutoCreated(true)
|
|
|
})
|
|
|
|
|
|
createEffect(
|
|
|
@@ -401,6 +411,32 @@ export default function Page() {
|
|
|
),
|
|
|
)
|
|
|
|
|
|
+ createEffect(
|
|
|
+ on(
|
|
|
+ () => terminal.active(),
|
|
|
+ (activeId) => {
|
|
|
+ if (!activeId || !view().terminal.opened()) return
|
|
|
+ // Immediately remove focus
|
|
|
+ if (document.activeElement instanceof HTMLElement) {
|
|
|
+ document.activeElement.blur()
|
|
|
+ }
|
|
|
+ const wrapper = document.getElementById(`terminal-wrapper-${activeId}`)
|
|
|
+ const element = wrapper?.querySelector('[data-component="terminal"]') as HTMLElement
|
|
|
+ if (!element) return
|
|
|
+
|
|
|
+ // Find and focus the ghostty textarea (the actual input element)
|
|
|
+ const textarea = element.querySelector("textarea") as HTMLTextAreaElement
|
|
|
+ if (textarea) {
|
|
|
+ textarea.focus()
|
|
|
+ return
|
|
|
+ }
|
|
|
+ // Fallback: focus container and dispatch pointer event
|
|
|
+ element.focus()
|
|
|
+ element.dispatchEvent(new PointerEvent("pointerdown", { bubbles: true, cancelable: true }))
|
|
|
+ },
|
|
|
+ ),
|
|
|
+ )
|
|
|
+
|
|
|
createEffect(
|
|
|
on(
|
|
|
() => visibleUserMessages().at(-1)?.id,
|
|
|
@@ -753,6 +789,9 @@ export default function Page() {
|
|
|
return
|
|
|
}
|
|
|
|
|
|
+ // Don't autofocus chat if terminal panel is open
|
|
|
+ if (view().terminal.opened()) return
|
|
|
+
|
|
|
if (event.key.length === 1 && event.key !== "Unidentified" && !(event.ctrlKey || event.metaKey)) {
|
|
|
inputRef?.focus()
|
|
|
}
|
|
|
@@ -800,6 +839,23 @@ export default function Page() {
|
|
|
|
|
|
const handleTerminalDragEnd = () => {
|
|
|
setStore("activeTerminalDraggable", undefined)
|
|
|
+ const activeId = terminal.active()
|
|
|
+ if (!activeId) return
|
|
|
+ setTimeout(() => {
|
|
|
+ const wrapper = document.getElementById(`terminal-wrapper-${activeId}`)
|
|
|
+ const element = wrapper?.querySelector('[data-component="terminal"]') as HTMLElement
|
|
|
+ if (!element) return
|
|
|
+
|
|
|
+ // Find and focus the ghostty textarea (the actual input element)
|
|
|
+ const textarea = element.querySelector("textarea") as HTMLTextAreaElement
|
|
|
+ if (textarea) {
|
|
|
+ textarea.focus()
|
|
|
+ return
|
|
|
+ }
|
|
|
+ // Fallback: focus container and dispatch pointer event
|
|
|
+ element.focus()
|
|
|
+ element.dispatchEvent(new PointerEvent("pointerdown", { bubbles: true, cancelable: true }))
|
|
|
+ }, 0)
|
|
|
}
|
|
|
|
|
|
const contextOpen = createMemo(() => tabs().active() === "context" || tabs().all().includes("context"))
|
|
|
@@ -1855,7 +1911,7 @@ export default function Page() {
|
|
|
|
|
|
<Show when={isDesktop() && view().terminal.opened()}>
|
|
|
<div
|
|
|
- class="relative w-full flex-col shrink-0 border-t border-border-weak-base"
|
|
|
+ class="relative w-full flex flex-col shrink-0 border-t border-border-weak-base"
|
|
|
style={{ height: `${layout.terminal.height()}px` }}
|
|
|
>
|
|
|
<ResizeHandle
|
|
|
@@ -1896,29 +1952,101 @@ export default function Page() {
|
|
|
>
|
|
|
<DragDropSensors />
|
|
|
<ConstrainDragYAxis />
|
|
|
- <Tabs variant="alt" value={terminal.active()} onChange={terminal.open}>
|
|
|
- <Tabs.List class="h-10">
|
|
|
- <SortableProvider ids={terminal.all().map((t: LocalPTY) => t.id)}>
|
|
|
- <For each={terminal.all()}>{(pty) => <SortableTerminalTab terminal={pty} />}</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} />
|
|
|
- </TooltipKeybind>
|
|
|
- </div>
|
|
|
- </Tabs.List>
|
|
|
- <For each={terminal.all()}>
|
|
|
- {(pty) => (
|
|
|
- <Tabs.Content value={pty.id}>
|
|
|
- <Terminal pty={pty} onCleanup={terminal.update} onConnectError={() => terminal.clone(pty.id)} />
|
|
|
- </Tabs.Content>
|
|
|
- )}
|
|
|
- </For>
|
|
|
- </Tabs>
|
|
|
+ <div class="flex flex-col h-full">
|
|
|
+ <Tabs
|
|
|
+ variant="alt"
|
|
|
+ value={terminal.active()}
|
|
|
+ onChange={(id) => {
|
|
|
+ // Only switch tabs if not in the middle of starting edit mode
|
|
|
+ terminal.open(id)
|
|
|
+ }}
|
|
|
+ class="!h-auto !flex-none"
|
|
|
+ >
|
|
|
+ <Tabs.List class="h-10">
|
|
|
+ <SortableProvider ids={terminal.all().map((t: LocalPTY) => t.id)}>
|
|
|
+ <For each={terminal.all()}>
|
|
|
+ {(pty) => (
|
|
|
+ <SortableTerminalTab
|
|
|
+ terminal={pty}
|
|
|
+ onClose={() => {
|
|
|
+ view().terminal.close()
|
|
|
+ setAutoCreated(false)
|
|
|
+ }}
|
|
|
+ />
|
|
|
+ )}
|
|
|
+ </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} />
|
|
|
+ </TooltipKeybind>
|
|
|
+ </div>
|
|
|
+ </Tabs.List>
|
|
|
+ </Tabs>
|
|
|
+ <div class="flex-1 min-h-0 relative">
|
|
|
+ <For each={terminal.all()}>
|
|
|
+ {(pty) => {
|
|
|
+ const [dismissed, setDismissed] = createSignal(false)
|
|
|
+ return (
|
|
|
+ <div
|
|
|
+ id={`terminal-wrapper-${pty.id}`}
|
|
|
+ class="absolute inset-0"
|
|
|
+ style={{
|
|
|
+ display: terminal.active() === pty.id ? "block" : "none",
|
|
|
+ }}
|
|
|
+ >
|
|
|
+ <Terminal
|
|
|
+ pty={pty}
|
|
|
+ onCleanup={(data) => terminal.update({ ...data, id: pty.id })}
|
|
|
+ onConnectError={() => {
|
|
|
+ terminal.update({ id: pty.id, error: true })
|
|
|
+ }}
|
|
|
+ />
|
|
|
+ <Show when={pty.error && !dismissed()}>
|
|
|
+ <div
|
|
|
+ class="absolute inset-0 flex flex-col items-center justify-center gap-3"
|
|
|
+ style={{ "background-color": "rgba(0, 0, 0, 0.6)" }}
|
|
|
+ >
|
|
|
+ <Icon
|
|
|
+ name="circle-ban-sign"
|
|
|
+ class="w-8 h-8"
|
|
|
+ style={{ color: "rgba(239, 68, 68, 0.8)" }}
|
|
|
+ />
|
|
|
+ <div class="text-center" style={{ color: "rgba(255, 255, 255, 0.7)" }}>
|
|
|
+ <div class="text-14-semibold mb-1">Connection Lost</div>
|
|
|
+ <div class="text-12-regular" style={{ color: "rgba(255, 255, 255, 0.5)" }}>
|
|
|
+ The terminal connection was interrupted. This can happen when the server restarts.
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ <button
|
|
|
+ class="mt-2 px-3 py-1.5 text-12-medium rounded-lg transition-colors"
|
|
|
+ style={{
|
|
|
+ "background-color": "rgba(255, 255, 255, 0.1)",
|
|
|
+ color: "rgba(255, 255, 255, 0.7)",
|
|
|
+ border: "1px solid rgba(255, 255, 255, 0.2)",
|
|
|
+ }}
|
|
|
+ onMouseEnter={(e) =>
|
|
|
+ (e.currentTarget.style.backgroundColor = "rgba(255, 255, 255, 0.15)")
|
|
|
+ }
|
|
|
+ onMouseLeave={(e) =>
|
|
|
+ (e.currentTarget.style.backgroundColor = "rgba(255, 255, 255, 0.1)")
|
|
|
+ }
|
|
|
+ onClick={() => setDismissed(true)}
|
|
|
+ >
|
|
|
+ Dismiss
|
|
|
+ </button>
|
|
|
+ </div>
|
|
|
+ </Show>
|
|
|
+ </div>
|
|
|
+ )
|
|
|
+ }}
|
|
|
+ </For>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
<DragOverlay>
|
|
|
<Show when={store.activeTerminalDraggable}>
|
|
|
{(draggedId) => {
|