diff options
| -rw-r--r-- | packages/app/src/pages/layout.tsx | 2 | ||||
| -rw-r--r-- | packages/app/src/pages/session.tsx | 35 | ||||
| -rw-r--r-- | packages/app/src/pages/session/helpers.ts | 104 | ||||
| -rw-r--r-- | packages/app/src/pages/session/session-side-panel.tsx | 325 | ||||
| -rw-r--r-- | packages/app/src/pages/session/terminal-panel.tsx | 239 |
5 files changed, 450 insertions, 255 deletions
diff --git a/packages/app/src/pages/layout.tsx b/packages/app/src/pages/layout.tsx index 9c359aafb..70114623e 100644 --- a/packages/app/src/pages/layout.tsx +++ b/packages/app/src/pages/layout.tsx @@ -2252,7 +2252,7 @@ export default function Layout(props: ParentProps) { > <main classList={{ - "size-full overflow-x-hidden flex flex-col items-start contain-strict border-t border-border-weak-base xl:border-l xl:rounded-tl-[12px]": true, + "size-full overflow-x-hidden flex flex-col items-start contain-strict border-t border-border-weak-base bg-background-base xl:border-l xl:rounded-tl-[12px]": true, }} > <Show when={!autoselecting()} fallback={<div class="size-full" />}> diff --git a/packages/app/src/pages/session.tsx b/packages/app/src/pages/session.tsx index 077ab544d..cba49f5fb 100644 --- a/packages/app/src/pages/session.tsx +++ b/packages/app/src/pages/session.tsx @@ -33,7 +33,7 @@ import { usePrompt } from "@/context/prompt" import { useSDK } from "@/context/sdk" import { useSync } from "@/context/sync" import { createSessionComposerState, SessionComposerRegion } from "@/pages/session/composer" -import { createOpenReviewFile } from "@/pages/session/helpers" +import { createOpenReviewFile, createSizing } from "@/pages/session/helpers" import { MessageTimeline } from "@/pages/session/message-timeline" import { type DiffStyle, SessionReviewTab, type SessionReviewTabProps } from "@/pages/session/review-tab" import { createScrollSpy } from "@/pages/session/scroll-spy" @@ -332,6 +332,7 @@ export default function Page() { ) const isDesktop = createMediaQuery("(min-width: 768px)") + const size = createSizing() const desktopReviewOpen = createMemo(() => isDesktop() && view().reviewPanel.opened()) const desktopFileTreeOpen = createMemo(() => isDesktop() && layout.fileTree.opened()) const desktopSidePanelOpen = createMemo(() => desktopReviewOpen() || desktopFileTreeOpen()) @@ -1252,9 +1253,9 @@ export default function Page() { {/* Session panel */} <div classList={{ - "@container relative shrink-0 flex flex-col min-h-0 h-full bg-background-stronger": true, - "flex-1": true, - "md:flex-none": desktopSidePanelOpen(), + "@container relative shrink-0 flex flex-col min-h-0 h-full bg-background-stronger flex-1 md:flex-none": true, + "transition-[width] duration-[240ms] ease-[cubic-bezier(0.22,1,0.36,1)] will-change-[width] motion-reduce:transition-none": + !size.active(), }} style={{ width: sessionPanelWidth(), @@ -1351,17 +1352,27 @@ export default function Page() { /> <Show when={desktopReviewOpen()}> - <ResizeHandle - direction="horizontal" - size={layout.session.width()} - min={450} - max={typeof window === "undefined" ? 1000 : window.innerWidth * 0.45} - onResize={layout.session.resize} - /> + <div onPointerDown={() => size.start()}> + <ResizeHandle + direction="horizontal" + size={layout.session.width()} + min={450} + max={typeof window === "undefined" ? 1000 : window.innerWidth * 0.45} + onResize={(width) => { + size.touch() + layout.session.resize(width) + }} + /> + </div> </Show> </div> - <SessionSidePanel reviewPanel={reviewPanel} activeDiff={tree.activeDiff} focusReviewDiff={focusReviewDiff} /> + <SessionSidePanel + reviewPanel={reviewPanel} + activeDiff={tree.activeDiff} + focusReviewDiff={focusReviewDiff} + size={size} + /> </div> <TerminalPanel /> diff --git a/packages/app/src/pages/session/helpers.ts b/packages/app/src/pages/session/helpers.ts index 60b26cdf4..be9656900 100644 --- a/packages/app/src/pages/session/helpers.ts +++ b/packages/app/src/pages/session/helpers.ts @@ -1,4 +1,5 @@ -import { batch } from "solid-js" +import { batch, createEffect, on, onCleanup, onMount, type Accessor } from "solid-js" +import { createStore } from "solid-js/store" export const focusTerminalById = (id: string) => { const wrapper = document.getElementById(`terminal-wrapper-${id}`) @@ -69,3 +70,104 @@ export const getTabReorderIndex = (tabs: readonly string[], from: string, to: st if (fromIndex === -1 || toIndex === -1 || fromIndex === toIndex) return undefined return toIndex } + +export const createSizing = () => { + const [state, setState] = createStore({ active: false }) + let t: number | undefined + + const stop = () => { + if (t !== undefined) { + clearTimeout(t) + t = undefined + } + setState("active", false) + } + + const start = () => { + if (t !== undefined) { + clearTimeout(t) + t = undefined + } + setState("active", true) + } + + onMount(() => { + window.addEventListener("pointerup", stop) + window.addEventListener("pointercancel", stop) + window.addEventListener("blur", stop) + onCleanup(() => { + window.removeEventListener("pointerup", stop) + window.removeEventListener("pointercancel", stop) + window.removeEventListener("blur", stop) + }) + }) + + onCleanup(() => { + if (t !== undefined) clearTimeout(t) + }) + + return { + active: () => state.active, + start, + touch() { + start() + t = window.setTimeout(stop, 120) + }, + } +} + +export type Sizing = ReturnType<typeof createSizing> + +export const createPresence = (open: Accessor<boolean>, wait = 200) => { + const [state, setState] = createStore({ + show: open(), + open: open(), + }) + let frame: number | undefined + let t: number | undefined + + const clear = () => { + if (frame !== undefined) { + cancelAnimationFrame(frame) + frame = undefined + } + if (t !== undefined) { + clearTimeout(t) + t = undefined + } + } + + createEffect( + on(open, (next) => { + clear() + + if (next) { + if (state.show) { + setState("open", true) + return + } + + setState({ show: true, open: false }) + frame = requestAnimationFrame(() => { + frame = undefined + setState("open", true) + }) + return + } + + if (!state.show) return + setState("open", false) + t = window.setTimeout(() => { + t = undefined + setState("show", false) + }, wait) + }), + ) + + onCleanup(clear) + + return { + show: () => state.show, + open: () => state.open, + } +} diff --git a/packages/app/src/pages/session/session-side-panel.tsx b/packages/app/src/pages/session/session-side-panel.tsx index ffb6ab2e7..173b3db36 100644 --- a/packages/app/src/pages/session/session-side-panel.tsx +++ b/packages/app/src/pages/session/session-side-panel.tsx @@ -23,7 +23,7 @@ import { useLayout } from "@/context/layout" import { useSync } from "@/context/sync" import { createFileTabListSync } from "@/pages/session/file-tab-scroll" import { FileTabContent } from "@/pages/session/file-tabs" -import { createOpenSessionFileTab, getTabReorderIndex } from "@/pages/session/helpers" +import { createOpenSessionFileTab, getTabReorderIndex, type Sizing } from "@/pages/session/helpers" import { StickyAddButton } from "@/pages/session/review-tab" import { setSessionHandoff } from "@/pages/session/handoff" @@ -31,6 +31,7 @@ export function SessionSidePanel(props: { reviewPanel: () => JSX.Element activeDiff?: string focusReviewDiff: (path: string) => void + size: Sizing }) { const params = useParams() const layout = useLayout() @@ -46,8 +47,20 @@ export function SessionSidePanel(props: { const view = createMemo(() => layout.view(sessionKey)) const reviewOpen = createMemo(() => isDesktop() && view().reviewPanel.opened()) - const open = createMemo(() => isDesktop() && (view().reviewPanel.opened() || layout.fileTree.opened())) + const fileOpen = createMemo(() => isDesktop() && layout.fileTree.opened()) + const open = createMemo(() => reviewOpen() || fileOpen()) const reviewTab = createMemo(() => isDesktop()) + const panelWidth = createMemo(() => { + if (!open()) return "0px" + if (reviewOpen()) return `calc(100% - ${layout.session.width()}px)` + return `${layout.fileTree.width()}px` + }) + const reviewWidth = createMemo(() => { + if (!reviewOpen()) return "0px" + if (!fileOpen()) return "100%" + return `calc(100% - ${layout.fileTree.width()}px)` + }) + const treeWidth = createMemo(() => (fileOpen() ? `${layout.fileTree.width()}px` : "0px")) const info = createMemo(() => (params.id ? sync.session.get(params.id) : undefined)) const diffs = createMemo(() => (params.id ? (sync.data.session_diff[params.id] ?? []) : [])) @@ -210,146 +223,175 @@ export function SessionSidePanel(props: { }) return ( - <Show when={open()}> + <Show when={isDesktop()}> <aside id="review-panel" aria-label={language.t("session.panel.reviewAndFiles")} - class="relative min-w-0 h-full border-l border-border-weaker-base flex" + aria-hidden={!open()} + inert={!open()} + class="relative min-w-0 h-full flex shrink-0 overflow-hidden bg-background-base" classList={{ - "flex-1": reviewOpen(), - "shrink-0": !reviewOpen(), + "opacity-100": open(), + "opacity-0 pointer-events-none": !open(), + "transition-[width,opacity] duration-[240ms] ease-[cubic-bezier(0.22,1,0.36,1)] will-change-[width] motion-reduce:transition-none": + !props.size.active(), }} - style={{ width: reviewOpen() ? undefined : `${layout.fileTree.width()}px` }} + style={{ width: panelWidth() }} > - <Show when={reviewOpen()}> - <div class="flex-1 min-w-0 h-full"> - <DragDropProvider - onDragStart={handleDragStart} - onDragEnd={handleDragEnd} - onDragOver={handleDragOver} - collisionDetector={closestCenter} - > - <DragDropSensors /> - <ConstrainDragYAxis /> - <Tabs value={activeTab()} onChange={openTab}> - <div class="sticky top-0 shrink-0 flex"> - <Tabs.List - ref={(el: HTMLDivElement) => { - const stop = createFileTabListSync({ el, contextOpen }) - onCleanup(stop) - }} - > - <Show when={reviewTab()}> - <Tabs.Trigger value="review"> - <div class="flex items-center gap-1.5"> - <div>{language.t("session.tab.review")}</div> - <Show when={hasReview()}> - <div>{reviewCount()}</div> - </Show> - </div> - </Tabs.Trigger> - </Show> - <Show when={contextOpen()}> - <Tabs.Trigger - value="context" - closeButton={ - <TooltipKeybind - title={language.t("common.closeTab")} - keybind={command.keybind("tab.close")} - placement="bottom" - gutter={10} - > - <IconButton - icon="close-small" - variant="ghost" - class="h-5 w-5" - onClick={() => tabs().close("context")} - aria-label={language.t("common.closeTab")} - /> - </TooltipKeybind> - } - hideCloseButton - onMiddleClick={() => tabs().close("context")} - > - <div class="flex items-center gap-2"> - <SessionContextUsage variant="indicator" /> - <div>{language.t("session.tab.context")}</div> - </div> - </Tabs.Trigger> - </Show> - <SortableProvider ids={openedTabs()}> - <For each={openedTabs()}>{(tab) => <SortableTab tab={tab} onTabClose={tabs().close} />}</For> - </SortableProvider> - <StickyAddButton> - <TooltipKeybind - title={language.t("command.file.open")} - keybind={command.keybind("file.open")} - class="flex items-center" - > - <IconButton - icon="plus-small" - variant="ghost" - iconSize="large" - class="!rounded-md" - onClick={() => dialog.show(() => <DialogSelectFile mode="files" onOpenFile={showAllFiles} />)} - aria-label={language.t("command.file.open")} - /> - </TooltipKeybind> - </StickyAddButton> - </Tabs.List> - </div> - - <Show when={reviewTab()}> - <Tabs.Content value="review" class="flex flex-col h-full overflow-hidden contain-strict"> - <Show when={activeTab() === "review"}>{props.reviewPanel()}</Show> - </Tabs.Content> - </Show> - - <Tabs.Content value="empty" class="flex flex-col h-full overflow-hidden contain-strict"> - <Show when={activeTab() === "empty"}> - <div class="relative pt-2 flex-1 min-h-0 overflow-hidden"> - <div class="h-full px-6 pb-42 flex flex-col items-center justify-center text-center gap-6"> - <Mark class="w-14 opacity-10" /> - <div class="text-14-regular text-text-weak max-w-56"> - {language.t("session.files.selectToOpen")} - </div> - </div> - </div> + <div class="size-full flex border-l border-border-weaker-base"> + <div + aria-hidden={!reviewOpen()} + inert={!reviewOpen()} + class="relative min-w-0 h-full shrink-0 overflow-hidden bg-background-base" + classList={{ + "opacity-100": reviewOpen(), + "opacity-0 pointer-events-none": !reviewOpen(), + "transition-[width,opacity] duration-[240ms] ease-[cubic-bezier(0.22,1,0.36,1)] will-change-[width] motion-reduce:transition-none": + !props.size.active(), + }} + style={{ width: reviewWidth() }} + > + <div class="size-full min-w-0 h-full bg-background-base"> + <DragDropProvider + onDragStart={handleDragStart} + onDragEnd={handleDragEnd} + onDragOver={handleDragOver} + collisionDetector={closestCenter} + > + <DragDropSensors /> + <ConstrainDragYAxis /> + <Tabs value={activeTab()} onChange={openTab}> + <div class="sticky top-0 shrink-0 flex"> + <Tabs.List + ref={(el: HTMLDivElement) => { + const stop = createFileTabListSync({ el, contextOpen }) + onCleanup(stop) + }} + > + <Show when={reviewTab()}> + <Tabs.Trigger value="review"> + <div class="flex items-center gap-1.5"> + <div>{language.t("session.tab.review")}</div> + <Show when={hasReview()}> + <div>{reviewCount()}</div> + </Show> + </div> + </Tabs.Trigger> + </Show> + <Show when={contextOpen()}> + <Tabs.Trigger + value="context" + closeButton={ + <TooltipKeybind + title={language.t("common.closeTab")} + keybind={command.keybind("tab.close")} + placement="bottom" + gutter={10} + > + <IconButton + icon="close-small" + variant="ghost" + class="h-5 w-5" + onClick={() => tabs().close("context")} + aria-label={language.t("common.closeTab")} + /> + </TooltipKeybind> + } + hideCloseButton + onMiddleClick={() => tabs().close("context")} + > + <div class="flex items-center gap-2"> + <SessionContextUsage variant="indicator" /> + <div>{language.t("session.tab.context")}</div> + </div> + </Tabs.Trigger> + </Show> + <SortableProvider ids={openedTabs()}> + <For each={openedTabs()}>{(tab) => <SortableTab tab={tab} onTabClose={tabs().close} />}</For> + </SortableProvider> + <StickyAddButton> + <TooltipKeybind + title={language.t("command.file.open")} + keybind={command.keybind("file.open")} + class="flex items-center" + > + <IconButton + icon="plus-small" + variant="ghost" + iconSize="large" + class="!rounded-md" + onClick={() => + dialog.show(() => <DialogSelectFile mode="files" onOpenFile={showAllFiles} />) + } + aria-label={language.t("command.file.open")} + /> + </TooltipKeybind> + </StickyAddButton> + </Tabs.List> + </div> + + <Show when={reviewTab()}> + <Tabs.Content value="review" class="flex flex-col h-full overflow-hidden contain-strict"> + <Show when={activeTab() === "review"}>{props.reviewPanel()}</Show> + </Tabs.Content> </Show> - </Tabs.Content> - <Show when={contextOpen()}> - <Tabs.Content value="context" class="flex flex-col h-full overflow-hidden contain-strict"> - <Show when={activeTab() === "context"}> + <Tabs.Content value="empty" class="flex flex-col h-full overflow-hidden contain-strict"> + <Show when={activeTab() === "empty"}> <div class="relative pt-2 flex-1 min-h-0 overflow-hidden"> - <SessionContextTab /> + <div class="h-full px-6 pb-42 flex flex-col items-center justify-center text-center gap-6"> + <Mark class="w-14 opacity-10" /> + <div class="text-14-regular text-text-weak max-w-56"> + {language.t("session.files.selectToOpen")} + </div> + </div> </div> </Show> </Tabs.Content> - </Show> - <Show when={activeFileTab()} keyed> - {(tab) => <FileTabContent tab={tab} />} - </Show> - </Tabs> - <DragOverlay> - <Show when={store.activeDraggable} keyed> - {(tab) => { - const path = createMemo(() => file.pathFromTab(tab)) - return ( - <div data-component="tabs-drag-preview"> - <Show when={path()}>{(p) => <FileVisual active path={p()} />}</Show> - </div> - ) - }} - </Show> - </DragOverlay> - </DragDropProvider> + <Show when={contextOpen()}> + <Tabs.Content value="context" class="flex flex-col h-full overflow-hidden contain-strict"> + <Show when={activeTab() === "context"}> + <div class="relative pt-2 flex-1 min-h-0 overflow-hidden"> + <SessionContextTab /> + </div> + </Show> + </Tabs.Content> + </Show> + + <Show when={activeFileTab()} keyed> + {(tab) => <FileTabContent tab={tab} />} + </Show> + </Tabs> + <DragOverlay> + <Show when={store.activeDraggable} keyed> + {(tab) => { + const path = createMemo(() => file.pathFromTab(tab)) + return ( + <div data-component="tabs-drag-preview"> + <Show when={path()}>{(p) => <FileVisual active path={p()} />}</Show> + </div> + ) + }} + </Show> + </DragOverlay> + </DragDropProvider> + </div> </div> - </Show> - <Show when={layout.fileTree.opened()}> - <div id="file-tree-panel" class="relative shrink-0 h-full" style={{ width: `${layout.fileTree.width()}px` }}> + <div + id="file-tree-panel" + aria-hidden={!fileOpen()} + inert={!fileOpen()} + class="relative min-w-0 h-full shrink-0 overflow-hidden" + classList={{ + "opacity-100": fileOpen(), + "opacity-0 pointer-events-none": !fileOpen(), + "transition-[width,opacity] duration-200 ease-[cubic-bezier(0.22,1,0.36,1)] will-change-[width] motion-reduce:transition-none": + !props.size.active(), + }} + style={{ width: treeWidth() }} + > <div class="h-full flex flex-col overflow-hidden group/filetree" classList={{ "border-l border-border-weaker-base": reviewOpen() }} @@ -412,18 +454,25 @@ export function SessionSidePanel(props: { </Tabs.Content> </Tabs> </div> - <ResizeHandle - direction="horizontal" - edge="start" - size={layout.fileTree.width()} - min={200} - max={480} - collapseThreshold={160} - onResize={layout.fileTree.resize} - onCollapse={layout.fileTree.close} - /> + <Show when={fileOpen()}> + <div onPointerDown={() => props.size.start()}> + <ResizeHandle + direction="horizontal" + edge="start" + size={layout.fileTree.width()} + min={200} + max={480} + collapseThreshold={160} + onResize={(width) => { + props.size.touch() + layout.fileTree.resize(width) + }} + onCollapse={layout.fileTree.close} + /> + </div> + </Show> </div> - </Show> + </div> </aside> </Show> ) diff --git a/packages/app/src/pages/session/terminal-panel.tsx b/packages/app/src/pages/session/terminal-panel.tsx index 69c8aefcc..d5eac2322 100644 --- a/packages/app/src/pages/session/terminal-panel.tsx +++ b/packages/app/src/pages/session/terminal-panel.tsx @@ -17,7 +17,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 { focusTerminalById } from "@/pages/session/helpers" +import { createPresence, createSizing, focusTerminalById } from "@/pages/session/helpers" import { getTerminalHandoff, setTerminalHandoff } from "@/pages/session/handoff" export function TerminalPanel() { @@ -33,8 +33,11 @@ export function TerminalPanel() { 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() + let root: HTMLDivElement | undefined const [store, setStore] = createStore({ autoCreated: false, @@ -67,7 +70,7 @@ export function TerminalPanel() { on( () => terminal.active(), (activeId) => { - if (!activeId || !open()) return + if (!activeId || !panel.open()) return if (document.activeElement instanceof HTMLElement) { document.activeElement.blur() } @@ -77,6 +80,14 @@ export function TerminalPanel() { ) createEffect(() => { + if (panel.open()) 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 @@ -133,120 +144,142 @@ export function TerminalPanel() { } return ( - <Show when={open()}> + <Show when={panel.show()}> <div + ref={root} id="terminal-panel" role="region" aria-label={language.t("terminal.title")} - class="relative w-full flex flex-col shrink-0 border-t border-border-weak-base" - style={{ height: `${height()}px` }} + aria-hidden={!panel.open()} + inert={!panel.open()} + class="relative w-full shrink-0 overflow-hidden" + 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": + !size.active(), + }} + style={{ height: panel.open() ? `${height()}px` : "0px" }} > - <ResizeHandle - direction="vertical" - size={height()} - min={100} - max={typeof window === "undefined" ? 1000 : window.innerHeight * 0.6} - collapseThreshold={50} - onResize={layout.terminal.resize} - onCollapse={close} - /> - <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 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> - <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()} onCleanup={terminal.update} onConnectError={() => terminal.clone(id)} /> + <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()} + 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> - </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> + </DragOverlay> + </DragDropProvider> + </Show> + </div> </div> </Show> ) |
