feat(app): terminal improvements (#34747)
Co-authored-by: Brendan Allan <git@brendonovich.dev>
This commit is contained in:
parent
78bca5c707
commit
7cf8d1ca6d
10 changed files with 810 additions and 25 deletions
|
|
@ -7,7 +7,7 @@ const projectID = "proj_hidden_terminal_regression"
|
||||||
const sessionID = "ses_hidden_terminal_regression"
|
const sessionID = "ses_hidden_terminal_regression"
|
||||||
const title = "Hidden terminal regression"
|
const title = "Hidden terminal regression"
|
||||||
|
|
||||||
test("unmounts the terminal renderer while the pane is hidden", async ({ page }) => {
|
test("unmounts the terminal panel while it is hidden", async ({ page }) => {
|
||||||
await page.setViewportSize({ width: 1400, height: 900 })
|
await page.setViewportSize({ width: 1400, height: 900 })
|
||||||
await mockOpenCodeServer(page, {
|
await mockOpenCodeServer(page, {
|
||||||
directory,
|
directory,
|
||||||
|
|
@ -64,13 +64,14 @@ test("unmounts the terminal renderer while the pane is hidden", async ({ page })
|
||||||
await expect(page.locator('[data-component="terminal"]')).toBeVisible()
|
await expect(page.locator('[data-component="terminal"]')).toBeVisible()
|
||||||
|
|
||||||
await page.keyboard.press("Control+Backquote")
|
await page.keyboard.press("Control+Backquote")
|
||||||
await expect(panel).toHaveAttribute("aria-hidden", "true")
|
await expect(panel).toHaveCount(0)
|
||||||
await expect(page.locator('[data-component="terminal"]')).toHaveCount(0)
|
await expect(page.locator('[data-component="terminal"]')).toHaveCount(0)
|
||||||
|
|
||||||
await page.setViewportSize({ width: 1200, height: 700 })
|
await page.setViewportSize({ width: 1200, height: 700 })
|
||||||
await expect(page.locator('[data-component="terminal"]')).toHaveCount(0)
|
await expect(page.locator('[data-component="terminal"]')).toHaveCount(0)
|
||||||
|
|
||||||
await page.keyboard.press("Control+Backquote")
|
await page.keyboard.press("Control+Backquote")
|
||||||
|
await expect(panel).toBeVisible()
|
||||||
await expect(page.locator('[data-component="terminal"]')).toBeVisible()
|
await expect(page.locator('[data-component="terminal"]')).toBeVisible()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import type { Page, Route } from "@playwright/test"
|
import type { Page, Route } from "@playwright/test"
|
||||||
|
|
||||||
const emptyList = new Set(["/skill", "/command", "/lsp", "/formatter", "/vcs/status", "/vcs/diff"])
|
const emptyList = new Set(["/skill", "/command", "/lsp", "/formatter", "/vcs/status", "/vcs/diff"])
|
||||||
const emptyObject = new Set(["/global/config", "/config", "/provider/auth", "/mcp"])
|
const emptyObject = new Set(["/global/config", "/config", "/provider/auth", "/mcp", "/experimental/resource"])
|
||||||
|
|
||||||
export interface MockServerConfig {
|
export interface MockServerConfig {
|
||||||
provider: unknown
|
provider: unknown
|
||||||
|
|
@ -55,6 +55,7 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
|
||||||
const path = url.pathname
|
const path = url.pathname
|
||||||
if (path === "/global/event" || path === "/event") return sse(route, config.events?.(), config.eventRetry)
|
if (path === "/global/event" || path === "/event") return sse(route, config.events?.(), config.eventRetry)
|
||||||
if (path === "/global/health") return json(route, { healthy: true })
|
if (path === "/global/health") return json(route, { healthy: true })
|
||||||
|
if (path === "/experimental/capabilities") return json(route, { backgroundSubagents: false })
|
||||||
if (path === "/permission")
|
if (path === "/permission")
|
||||||
return json(route, typeof config.permissions === "function" ? config.permissions() : (config.permissions ?? []))
|
return json(route, typeof config.permissions === "function" ? config.permissions() : (config.permissions ?? []))
|
||||||
if (path === "/question")
|
if (path === "/question")
|
||||||
|
|
@ -65,6 +66,11 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
|
||||||
return json(route, await config.fileList(url.searchParams.get("path") ?? ""))
|
return json(route, await config.fileList(url.searchParams.get("path") ?? ""))
|
||||||
if (path === "/file/content" && config.fileContent)
|
if (path === "/file/content" && config.fileContent)
|
||||||
return json(route, await config.fileContent(url.searchParams.get("path") ?? ""))
|
return json(route, await config.fileContent(url.searchParams.get("path") ?? ""))
|
||||||
|
if (path === "/api/reference")
|
||||||
|
return json(route, {
|
||||||
|
location: { directory: config.directory, project: { id: (config.project as { id?: string }).id, directory: config.directory } },
|
||||||
|
data: [],
|
||||||
|
})
|
||||||
if (emptyObject.has(path)) return json(route, {})
|
if (emptyObject.has(path)) return json(route, {})
|
||||||
if (emptyList.has(path)) return json(route, [])
|
if (emptyList.has(path)) return json(route, [])
|
||||||
if (path in staticRoutes) return json(route, staticRoutes[path])
|
if (path in staticRoutes) return json(route, staticRoutes[path])
|
||||||
|
|
@ -75,6 +81,9 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
|
||||||
return json(route, session ?? {})
|
return json(route, session ?? {})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const projectMatch = path.match(/^\/project\/([^/]+)$/)
|
||||||
|
if (projectMatch) return json(route, config.project)
|
||||||
|
|
||||||
const messageMatch = path.match(/^\/session\/([^/]+)\/message\/([^/]+)$/)
|
const messageMatch = path.match(/^\/session\/([^/]+)\/message\/([^/]+)$/)
|
||||||
if (messageMatch) {
|
if (messageMatch) {
|
||||||
config.onMessage?.({ sessionID: messageMatch[1]!, messageID: messageMatch[2]! })
|
config.onMessage?.({ sessionID: messageMatch[1]!, messageID: messageMatch[2]! })
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,271 @@
|
||||||
|
import type { JSX } from "solid-js"
|
||||||
|
import { Show, createEffect, onCleanup } from "solid-js"
|
||||||
|
import { createStore } from "solid-js/store"
|
||||||
|
import { useSortable } from "@dnd-kit/solid/sortable"
|
||||||
|
import { IconButton } from "@opencode-ai/ui/icon-button"
|
||||||
|
import { Tabs } from "@opencode-ai/ui/tabs"
|
||||||
|
import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu"
|
||||||
|
import { Icon } from "@opencode-ai/ui/icon"
|
||||||
|
import { MenuV2 } from "@opencode-ai/ui/v2/menu-v2"
|
||||||
|
import { isDefaultTitle as isDefaultTerminalTitle } from "@/context/terminal-title"
|
||||||
|
import { useTerminal, type LocalPTY } from "@/context/terminal"
|
||||||
|
import { useLanguage } from "@/context/language"
|
||||||
|
import { focusTerminalById } from "@/pages/session/helpers"
|
||||||
|
|
||||||
|
export function SortableTerminalTabV2(props: {
|
||||||
|
terminal: LocalPTY
|
||||||
|
index: () => number
|
||||||
|
newLayout: boolean
|
||||||
|
onClose?: () => void
|
||||||
|
}): JSX.Element {
|
||||||
|
const terminal = useTerminal()
|
||||||
|
const language = useLanguage()
|
||||||
|
const sortable = useSortable({
|
||||||
|
get id() {
|
||||||
|
return props.terminal.id
|
||||||
|
},
|
||||||
|
get index() {
|
||||||
|
return props.index()
|
||||||
|
},
|
||||||
|
})
|
||||||
|
const [store, setStore] = createStore({
|
||||||
|
editing: false,
|
||||||
|
title: props.terminal.title,
|
||||||
|
menuOpen: false,
|
||||||
|
menuPosition: { x: 0, y: 0 },
|
||||||
|
blurEnabled: false,
|
||||||
|
})
|
||||||
|
let input: HTMLInputElement | undefined
|
||||||
|
let blurFrame: number | undefined
|
||||||
|
let editRequested = false
|
||||||
|
|
||||||
|
const isDefaultTitle = () => {
|
||||||
|
const number = props.terminal.titleNumber
|
||||||
|
if (!Number.isFinite(number) || number <= 0) return false
|
||||||
|
return isDefaultTerminalTitle(props.terminal.title, number)
|
||||||
|
}
|
||||||
|
|
||||||
|
const label = () => {
|
||||||
|
language.locale()
|
||||||
|
if (props.terminal.title && !isDefaultTitle()) return props.terminal.title
|
||||||
|
|
||||||
|
const number = props.terminal.titleNumber
|
||||||
|
if (Number.isFinite(number) && number > 0) return language.t("terminal.title.numbered", { number })
|
||||||
|
if (props.terminal.title) return props.terminal.title
|
||||||
|
return language.t("terminal.title")
|
||||||
|
}
|
||||||
|
|
||||||
|
const close = () => {
|
||||||
|
const count = terminal.all().length
|
||||||
|
void terminal.close(props.terminal.id)
|
||||||
|
if (count === 1) {
|
||||||
|
props.onClose?.()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const focus = () => {
|
||||||
|
if (store.editing) return
|
||||||
|
terminal.open(props.terminal.id)
|
||||||
|
if (document.activeElement instanceof HTMLElement) document.activeElement.blur()
|
||||||
|
focusTerminalById(props.terminal.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
const edit = (e?: Event) => {
|
||||||
|
if (e) {
|
||||||
|
e.stopPropagation()
|
||||||
|
e.preventDefault()
|
||||||
|
}
|
||||||
|
|
||||||
|
setStore("blurEnabled", false)
|
||||||
|
setStore("title", props.terminal.title)
|
||||||
|
setStore("editing", true)
|
||||||
|
}
|
||||||
|
|
||||||
|
const save = () => {
|
||||||
|
if (!store.blurEnabled) return
|
||||||
|
|
||||||
|
const value = store.title.trim()
|
||||||
|
if (value && value !== props.terminal.title) {
|
||||||
|
terminal.update({ id: props.terminal.id, title: value })
|
||||||
|
}
|
||||||
|
setStore("editing", false)
|
||||||
|
}
|
||||||
|
|
||||||
|
const keydown = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === "Enter") {
|
||||||
|
e.preventDefault()
|
||||||
|
save()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (e.key === "Escape") {
|
||||||
|
e.preventDefault()
|
||||||
|
setStore("editing", false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const menu = (e: MouseEvent) => {
|
||||||
|
e.preventDefault()
|
||||||
|
setStore("menuPosition", { x: e.clientX, y: e.clientY })
|
||||||
|
setStore("menuOpen", true)
|
||||||
|
}
|
||||||
|
|
||||||
|
createEffect(() => {
|
||||||
|
if (!store.editing) return
|
||||||
|
if (!input) return
|
||||||
|
input.focus()
|
||||||
|
input.select()
|
||||||
|
if (blurFrame !== undefined) cancelAnimationFrame(blurFrame)
|
||||||
|
blurFrame = requestAnimationFrame(() => {
|
||||||
|
blurFrame = undefined
|
||||||
|
setStore("blurEnabled", true)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
onCleanup(() => {
|
||||||
|
if (blurFrame === undefined) return
|
||||||
|
cancelAnimationFrame(blurFrame)
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={sortable.ref}
|
||||||
|
class="outline-none focus:outline-none focus-visible:outline-none"
|
||||||
|
classList={{
|
||||||
|
"h-full flex items-center": props.newLayout,
|
||||||
|
"h-full": !props.newLayout,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Show
|
||||||
|
when={props.newLayout}
|
||||||
|
fallback={
|
||||||
|
<div class="relative h-full">
|
||||||
|
<Tabs.Trigger
|
||||||
|
value={props.terminal.id}
|
||||||
|
onClick={focus}
|
||||||
|
onMouseDown={(e) => e.preventDefault()}
|
||||||
|
onContextMenu={menu}
|
||||||
|
class="!shadow-none"
|
||||||
|
classes={{
|
||||||
|
button: "border-0 outline-none focus:outline-none focus-visible:outline-none !shadow-none !ring-0",
|
||||||
|
}}
|
||||||
|
closeButton={
|
||||||
|
<IconButton
|
||||||
|
icon="close"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
close()
|
||||||
|
}}
|
||||||
|
aria-label={language.t("terminal.close")}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<span onDblClick={edit} classList={{ invisible: store.editing }}>
|
||||||
|
{label()}
|
||||||
|
</span>
|
||||||
|
</Tabs.Trigger>
|
||||||
|
<Show when={store.editing}>
|
||||||
|
<div class="absolute inset-0 flex items-center px-3 bg-muted z-10 pointer-events-auto">
|
||||||
|
<input
|
||||||
|
ref={input}
|
||||||
|
type="text"
|
||||||
|
value={store.title}
|
||||||
|
onInput={(e) => setStore("title", e.currentTarget.value)}
|
||||||
|
onBlur={save}
|
||||||
|
onKeyDown={keydown}
|
||||||
|
onMouseDown={(e) => e.stopPropagation()}
|
||||||
|
class="bg-transparent border-none outline-none text-sm min-w-0 flex-1"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
<DropdownMenu open={store.menuOpen} onOpenChange={(open) => setStore("menuOpen", open)}>
|
||||||
|
<DropdownMenu.Portal>
|
||||||
|
<DropdownMenu.Content
|
||||||
|
class="fixed"
|
||||||
|
style={{
|
||||||
|
left: `${store.menuPosition.x}px`,
|
||||||
|
top: `${store.menuPosition.y}px`,
|
||||||
|
}}
|
||||||
|
onCloseAutoFocus={(e) => {
|
||||||
|
if (!editRequested) return
|
||||||
|
e.preventDefault()
|
||||||
|
editRequested = false
|
||||||
|
requestAnimationFrame(() => edit())
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<DropdownMenu.Item onSelect={() => (editRequested = true)}>
|
||||||
|
<Icon name="edit" class="w-4 h-4 mr-2" />
|
||||||
|
{language.t("common.rename")}
|
||||||
|
</DropdownMenu.Item>
|
||||||
|
<DropdownMenu.Item onSelect={close}>
|
||||||
|
<Icon name="close" class="w-4 h-4 mr-2" />
|
||||||
|
{language.t("common.close")}
|
||||||
|
</DropdownMenu.Item>
|
||||||
|
</DropdownMenu.Content>
|
||||||
|
</DropdownMenu.Portal>
|
||||||
|
</DropdownMenu>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<MenuV2.Context>
|
||||||
|
<MenuV2.Context.Trigger class="relative" as="div">
|
||||||
|
<Tabs.Trigger
|
||||||
|
value={props.terminal.id}
|
||||||
|
onClick={focus}
|
||||||
|
closeButton={
|
||||||
|
<IconButton
|
||||||
|
icon="close-small"
|
||||||
|
variant="ghost"
|
||||||
|
class="h-5 w-5"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
close()
|
||||||
|
}}
|
||||||
|
aria-label={language.t("terminal.close")}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
hideCloseButton
|
||||||
|
onMiddleClick={close}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
class="truncate"
|
||||||
|
data-slot="terminal-tab-title"
|
||||||
|
onDblClick={edit}
|
||||||
|
classList={{ invisible: store.editing }}
|
||||||
|
>
|
||||||
|
{label()}
|
||||||
|
</span>
|
||||||
|
</Tabs.Trigger>
|
||||||
|
<Show when={store.editing}>
|
||||||
|
<div class="absolute inset-0 flex items-center bg-v2-background-bg-layer-01 z-10 pointer-events-auto rounded-[6px] shadow-[inset_0_0_0_0.5px_var(--v2-border-border-muted)] px-2">
|
||||||
|
<input
|
||||||
|
ref={input}
|
||||||
|
type="text"
|
||||||
|
value={store.title}
|
||||||
|
onInput={(e) => setStore("title", e.currentTarget.value)}
|
||||||
|
onBlur={save}
|
||||||
|
onKeyDown={keydown}
|
||||||
|
onMouseDown={(e) => e.stopPropagation()}
|
||||||
|
class="bg-transparent border-none outline-none min-w-0 flex-1 p-0 text-[13px] leading-4 tracking-[-0.04px] text-v2-text-text-base [font-weight:440] [font-variation-settings:'slnt'_0] [font-variant-numeric:tabular-nums]"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
</MenuV2.Context.Trigger>
|
||||||
|
<MenuV2.Context.Portal>
|
||||||
|
<MenuV2.Context.Content
|
||||||
|
onCloseAutoFocus={(e) => {
|
||||||
|
if (!editRequested) return
|
||||||
|
e.preventDefault()
|
||||||
|
editRequested = false
|
||||||
|
requestAnimationFrame(() => edit())
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<MenuV2.Item onSelect={() => (editRequested = true)}>{language.t("common.rename")}</MenuV2.Item>
|
||||||
|
<MenuV2.Item onSelect={close}>{language.t("common.close")}</MenuV2.Item>
|
||||||
|
</MenuV2.Context.Content>
|
||||||
|
</MenuV2.Context.Portal>
|
||||||
|
</MenuV2.Context>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -17,8 +17,6 @@ import { createTabPromptState } from "@/context/prompt"
|
||||||
import { base64Encode } from "@opencode-ai/core/util/encode"
|
import { base64Encode } from "@opencode-ai/core/util/encode"
|
||||||
import { canStartTabDrag, isTabCloseTarget } from "./titlebar-tab-gesture"
|
import { canStartTabDrag, isTabCloseTarget } from "./titlebar-tab-gesture"
|
||||||
|
|
||||||
const sortableTransition = { duration: 0 }
|
|
||||||
|
|
||||||
function SessionTabSlot(props: {
|
function SessionTabSlot(props: {
|
||||||
tab: SessionTab
|
tab: SessionTab
|
||||||
id: string
|
id: string
|
||||||
|
|
|
||||||
|
|
@ -71,11 +71,13 @@ import { type DiffStyle, SessionReviewTab, type SessionReviewTabProps } from "@/
|
||||||
import { useSessionLayout } from "@/pages/session/session-layout"
|
import { useSessionLayout } from "@/pages/session/session-layout"
|
||||||
import { syncSessionModel } from "@/pages/session/session-model-helpers"
|
import { syncSessionModel } from "@/pages/session/session-model-helpers"
|
||||||
import { SessionSidePanel } from "@/pages/session/session-side-panel"
|
import { SessionSidePanel } from "@/pages/session/session-side-panel"
|
||||||
|
import { sessionPanelLayout } from "@/pages/session/session-panel-layout"
|
||||||
import { SessionReviewEmptyChangesV2 } from "@opencode-ai/session-ui/v2/session-review-empty-changes-v2"
|
import { SessionReviewEmptyChangesV2 } from "@opencode-ai/session-ui/v2/session-review-empty-changes-v2"
|
||||||
import { SessionReviewEmptyNoGitV2 } from "@opencode-ai/session-ui/v2/session-review-empty-no-git-v2"
|
import { SessionReviewEmptyNoGitV2 } from "@opencode-ai/session-ui/v2/session-review-empty-no-git-v2"
|
||||||
import { ReviewPanelV2 } from "@/pages/session/v2/review-panel-v2"
|
import { ReviewPanelV2 } from "@/pages/session/v2/review-panel-v2"
|
||||||
import { createReviewPanelV2State } from "@/pages/session/v2/review-panel-v2-state"
|
import { createReviewPanelV2State } from "@/pages/session/v2/review-panel-v2-state"
|
||||||
import { TerminalPanel } from "@/pages/session/terminal-panel"
|
import { TerminalPanel } from "@/pages/session/terminal-panel"
|
||||||
|
import { TerminalPanelV2 } from "@/pages/session/terminal-panel-v2"
|
||||||
import { useComposerCommands } from "@/pages/session/use-composer-commands"
|
import { useComposerCommands } from "@/pages/session/use-composer-commands"
|
||||||
import { useSessionCommands } from "@/pages/session/use-session-commands"
|
import { useSessionCommands } from "@/pages/session/use-session-commands"
|
||||||
import { useSessionHashScroll } from "@/pages/session/use-session-hash-scroll"
|
import { useSessionHashScroll } from "@/pages/session/use-session-hash-scroll"
|
||||||
|
|
@ -427,6 +429,12 @@ export default function Page() {
|
||||||
const isDesktop = createMediaQuery("(min-width: 768px)")
|
const isDesktop = createMediaQuery("(min-width: 768px)")
|
||||||
const size = createSizing()
|
const size = createSizing()
|
||||||
const desktopReviewOpen = createMemo(() => isDesktop() && view().reviewPanel.opened())
|
const desktopReviewOpen = createMemo(() => isDesktop() && view().reviewPanel.opened())
|
||||||
|
const desktopV2ReviewOpen = createMemo(() => newSessionDesign() && desktopReviewOpen() && !!params.id)
|
||||||
|
const terminalOpen = createMemo(() => view().terminal.opened())
|
||||||
|
const desktopTerminalOpen = createMemo(() => isDesktop() && terminalOpen())
|
||||||
|
const desktopInlineTerminalOnlyOpen = createMemo(
|
||||||
|
() => newSessionDesign() && desktopTerminalOpen() && !desktopV2ReviewOpen(),
|
||||||
|
)
|
||||||
const desktopFileTreeOpen = createMemo(
|
const desktopFileTreeOpen = createMemo(
|
||||||
() =>
|
() =>
|
||||||
isDesktop() &&
|
isDesktop() &&
|
||||||
|
|
@ -435,13 +443,23 @@ export default function Page() {
|
||||||
opened: layout.fileTree.opened(),
|
opened: layout.fileTree.opened(),
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
const desktopSidePanelOpen = createMemo(() => desktopReviewOpen() || desktopFileTreeOpen())
|
const desktopSessionResizeOpen = createMemo(
|
||||||
|
() => (newSessionDesign() ? desktopV2ReviewOpen() || desktopTerminalOpen() : desktopReviewOpen()),
|
||||||
|
)
|
||||||
|
const desktopSidePanelOpen = createMemo(() => desktopSessionResizeOpen() || desktopFileTreeOpen())
|
||||||
const sessionPanelWidth = createMemo(() => {
|
const sessionPanelWidth = createMemo(() => {
|
||||||
if (!desktopSidePanelOpen()) return "100%"
|
if (!desktopSidePanelOpen()) return "100%"
|
||||||
if (desktopReviewOpen()) return `${layout.session.width()}px`
|
if (desktopSessionResizeOpen()) return `${layout.session.width()}px`
|
||||||
return `calc(100% - ${layout.fileTree.width()}px)`
|
return `calc(100% - ${layout.fileTree.width()}px)`
|
||||||
})
|
})
|
||||||
const centered = createMemo(() => isDesktop() && !desktopReviewOpen())
|
const centered = createMemo(() => isDesktop() && !desktopReviewOpen())
|
||||||
|
const desktopV2PanelLayout = createMemo(() =>
|
||||||
|
sessionPanelLayout({
|
||||||
|
review: desktopV2ReviewOpen(),
|
||||||
|
terminal: desktopTerminalOpen(),
|
||||||
|
files: desktopFileTreeOpen(),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
function normalizeTab(tab: string) {
|
function normalizeTab(tab: string) {
|
||||||
if (!tab.startsWith("file://")) return tab
|
if (!tab.startsWith("file://")) return tab
|
||||||
|
|
@ -2093,7 +2111,7 @@ export default function Page() {
|
||||||
classList={{
|
classList={{
|
||||||
"@container relative shrink-0 flex flex-col min-h-0 h-full flex-1 md:flex-none transition-[width]": true,
|
"@container relative shrink-0 flex flex-col min-h-0 h-full flex-1 md:flex-none transition-[width]": true,
|
||||||
"duration-[240ms] ease-[cubic-bezier(0.22,1,0.36,1)] will-change-[width] motion-reduce:transition-none":
|
"duration-[240ms] ease-[cubic-bezier(0.22,1,0.36,1)] will-change-[width] motion-reduce:transition-none":
|
||||||
!size.active() && !ui.reviewSnap,
|
!size.active() && !ui.reviewSnap && !desktopInlineTerminalOnlyOpen(),
|
||||||
}}
|
}}
|
||||||
style={{
|
style={{
|
||||||
width: sessionPanelWidth(),
|
width: sessionPanelWidth(),
|
||||||
|
|
@ -2113,7 +2131,7 @@ export default function Page() {
|
||||||
</SessionPanelFrame>
|
</SessionPanelFrame>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<Show when={desktopReviewOpen()}>
|
<Show when={desktopSessionResizeOpen()}>
|
||||||
<div onPointerDown={() => size.start()}>
|
<div onPointerDown={() => size.start()}>
|
||||||
<ResizeHandle
|
<ResizeHandle
|
||||||
classList={{
|
classList={{
|
||||||
|
|
@ -2132,22 +2150,72 @@ export default function Page() {
|
||||||
</Show>
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<SessionSidePanel
|
<Show when={!newSessionDesign()}>
|
||||||
canReview={canReview}
|
<SessionSidePanel
|
||||||
diffs={reviewDiffs}
|
canReview={canReview}
|
||||||
diffsReady={reviewReady}
|
diffs={reviewDiffs}
|
||||||
empty={reviewEmptyText}
|
diffsReady={reviewReady}
|
||||||
hasReview={hasReview}
|
empty={reviewEmptyText}
|
||||||
reviewCount={reviewCount}
|
hasReview={hasReview}
|
||||||
reviewPanel={() => (newSessionDesign() ? reviewPanelV2() : reviewPanel())}
|
reviewCount={reviewCount}
|
||||||
activeDiff={tree.activeDiff}
|
reviewPanel={reviewPanel}
|
||||||
focusReviewDiff={focusReviewDiff}
|
activeDiff={tree.activeDiff}
|
||||||
reviewSnap={ui.reviewSnap}
|
focusReviewDiff={focusReviewDiff}
|
||||||
size={size}
|
reviewSnap={ui.reviewSnap}
|
||||||
/>
|
size={size}
|
||||||
|
/>
|
||||||
|
</Show>
|
||||||
|
<Show when={newSessionDesign()}>
|
||||||
|
<Show when={isDesktop() ? desktopV2PanelLayout().visible : terminalOpen()}>
|
||||||
|
<div class="min-w-0 h-full flex flex-1 flex-col">
|
||||||
|
<Show when={isDesktop() && (desktopV2ReviewOpen() || desktopFileTreeOpen())}>
|
||||||
|
<div class="min-h-0 flex-1">
|
||||||
|
<SessionSidePanel
|
||||||
|
canReview={canReview}
|
||||||
|
diffs={reviewDiffs}
|
||||||
|
diffsReady={reviewReady}
|
||||||
|
empty={reviewEmptyText}
|
||||||
|
hasReview={hasReview}
|
||||||
|
reviewCount={reviewCount}
|
||||||
|
reviewPanel={reviewPanelV2}
|
||||||
|
activeDiff={tree.activeDiff}
|
||||||
|
focusReviewDiff={focusReviewDiff}
|
||||||
|
reviewSnap={ui.reviewSnap}
|
||||||
|
size={size}
|
||||||
|
stacked={desktopV2PanelLayout().stacked}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
<Show when={desktopV2PanelLayout().stacked}>
|
||||||
|
<div class="relative h-2 shrink-0" onPointerDown={() => size.start()}>
|
||||||
|
<ResizeHandle
|
||||||
|
class="!relative !inset-auto !h-full !w-full !transform-none"
|
||||||
|
direction="vertical"
|
||||||
|
size={layout.terminal.height()}
|
||||||
|
min={100}
|
||||||
|
max={typeof window === "undefined" ? 600 : window.innerHeight * 0.6}
|
||||||
|
collapseThreshold={50}
|
||||||
|
onResize={(height) => {
|
||||||
|
size.touch()
|
||||||
|
layout.terminal.resize(height)
|
||||||
|
}}
|
||||||
|
onCollapse={() => view().terminal.close()}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
<Show when={terminalOpen()}>
|
||||||
|
<div class="min-h-0 flex-1">
|
||||||
|
<TerminalPanelV2 stacked={desktopV2PanelLayout().stacked} />
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<TerminalPanel />
|
<Show when={!newSessionDesign()}>
|
||||||
|
<TerminalPanel />
|
||||||
|
</Show>
|
||||||
</SessionRouteFrame>
|
</SessionRouteFrame>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
19
packages/app/src/pages/session/session-panel-layout.test.ts
Normal file
19
packages/app/src/pages/session/session-panel-layout.test.ts
Normal file
|
|
@ -0,0 +1,19 @@
|
||||||
|
import { describe, expect, test } from "bun:test"
|
||||||
|
import { sessionPanelLayout } from "./session-panel-layout"
|
||||||
|
|
||||||
|
describe("sessionPanelLayout", () => {
|
||||||
|
test("keeps one V2 owner while changing panel geometry", () => {
|
||||||
|
expect(sessionPanelLayout({ review: false, terminal: false, files: false })).toEqual({
|
||||||
|
visible: false,
|
||||||
|
stacked: false,
|
||||||
|
})
|
||||||
|
expect(sessionPanelLayout({ review: false, terminal: true, files: false })).toEqual({
|
||||||
|
visible: true,
|
||||||
|
stacked: false,
|
||||||
|
})
|
||||||
|
expect(sessionPanelLayout({ review: true, terminal: true, files: false })).toEqual({
|
||||||
|
visible: true,
|
||||||
|
stacked: true,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
6
packages/app/src/pages/session/session-panel-layout.ts
Normal file
6
packages/app/src/pages/session/session-panel-layout.ts
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
export function sessionPanelLayout(input: { review: boolean; terminal: boolean; files: boolean }) {
|
||||||
|
return {
|
||||||
|
visible: input.review || input.terminal || input.files,
|
||||||
|
stacked: input.review && input.terminal,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -51,6 +51,7 @@ export function SessionSidePanel(props: {
|
||||||
focusReviewDiff: (path: string) => void
|
focusReviewDiff: (path: string) => void
|
||||||
reviewSnap: boolean
|
reviewSnap: boolean
|
||||||
size: Sizing
|
size: Sizing
|
||||||
|
stacked?: boolean
|
||||||
}) {
|
}) {
|
||||||
const layout = useLayout()
|
const layout = useLayout()
|
||||||
const settings = useSettings()
|
const settings = useSettings()
|
||||||
|
|
@ -219,8 +220,10 @@ export function SessionSidePanel(props: {
|
||||||
aria-label={language.t("session.panel.reviewAndFiles")}
|
aria-label={language.t("session.panel.reviewAndFiles")}
|
||||||
aria-hidden={!open()}
|
aria-hidden={!open()}
|
||||||
inert={!open()}
|
inert={!open()}
|
||||||
class="relative min-w-0 h-full flex shrink-0 overflow-hidden bg-background-base"
|
class="relative min-w-0 flex overflow-hidden bg-background-base"
|
||||||
classList={{
|
classList={{
|
||||||
|
"h-full shrink-0": !props.stacked,
|
||||||
|
"min-h-0 flex-1": props.stacked,
|
||||||
"pointer-events-none": !open(),
|
"pointer-events-none": !open(),
|
||||||
"transition-[width] duration-[240ms] ease-[cubic-bezier(0.22,1,0.36,1)] will-change-[width] motion-reduce:transition-none":
|
"transition-[width] duration-[240ms] ease-[cubic-bezier(0.22,1,0.36,1)] will-change-[width] motion-reduce:transition-none":
|
||||||
!props.size.active() && !props.reviewSnap,
|
!props.size.active() && !props.reviewSnap,
|
||||||
|
|
|
||||||
364
packages/app/src/pages/session/terminal-panel-v2.tsx
Normal file
364
packages/app/src/pages/session/terminal-panel-v2.tsx
Normal file
|
|
@ -0,0 +1,364 @@
|
||||||
|
import { For, Show, createEffect, createMemo, on, onCleanup, onMount } from "solid-js"
|
||||||
|
import { createStore } from "solid-js/store"
|
||||||
|
import { makeEventListener } from "@solid-primitives/event-listener"
|
||||||
|
import { createMediaQuery } from "@solid-primitives/media"
|
||||||
|
import { DragDropProvider, PointerSensor } from "@dnd-kit/solid"
|
||||||
|
import { isSortable } from "@dnd-kit/solid/sortable"
|
||||||
|
import { Accessibility, AutoScroller, Feedback, PointerActivationConstraints } from "@dnd-kit/dom"
|
||||||
|
import { RestrictToHorizontalAxis } from "@dnd-kit/abstract/modifiers"
|
||||||
|
import { RestrictToElement } from "@dnd-kit/dom/modifiers"
|
||||||
|
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 { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2"
|
||||||
|
import { KeybindV2 } from "@opencode-ai/ui/v2/keybind-v2"
|
||||||
|
|
||||||
|
import { SortableTerminalTabV2 } from "@/components/session/session-sortable-terminal-tab-v2"
|
||||||
|
import { Terminal } from "@/components/terminal"
|
||||||
|
import { useCommand } from "@/context/command"
|
||||||
|
import { useLanguage } from "@/context/language"
|
||||||
|
import { useLayout } from "@/context/layout"
|
||||||
|
import { useSettings } from "@/context/settings"
|
||||||
|
import { useTerminal } from "@/context/terminal"
|
||||||
|
import { useSDK } from "@/context/sdk"
|
||||||
|
import { terminalTabLabel } from "@/pages/session/terminal-label"
|
||||||
|
import { createSizing, focusTerminalById } from "@/pages/session/helpers"
|
||||||
|
import { getTerminalHandoff, setTerminalHandoff } from "@/pages/session/handoff"
|
||||||
|
import { useSessionLayout } from "@/pages/session/session-layout"
|
||||||
|
|
||||||
|
export function TerminalPanelV2(props: { stacked?: boolean } = {}) {
|
||||||
|
const delays = [120, 240]
|
||||||
|
const layout = useLayout()
|
||||||
|
const terminal = useTerminal()
|
||||||
|
const sdk = useSDK()
|
||||||
|
const language = useLanguage()
|
||||||
|
const command = useCommand()
|
||||||
|
const settings = useSettings()
|
||||||
|
const { workspaceKey, view } = useSessionLayout()
|
||||||
|
|
||||||
|
const isDesktop = createMediaQuery("(min-width: 768px)")
|
||||||
|
const newLayout = createMemo(() => settings.general.newLayoutDesigns())
|
||||||
|
const opened = createMemo(() => view().terminal.opened())
|
||||||
|
const size = createSizing()
|
||||||
|
const height = createMemo(() => layout.terminal.height())
|
||||||
|
const close = () => view().terminal.close()
|
||||||
|
let root: HTMLDivElement | undefined
|
||||||
|
let tabList: HTMLDivElement | undefined
|
||||||
|
|
||||||
|
const [store, setStore] = createStore({
|
||||||
|
autoCreated: false,
|
||||||
|
recovered: {} as Record<string, boolean>,
|
||||||
|
view: typeof window === "undefined" ? 1000 : (window.visualViewport?.height ?? window.innerHeight),
|
||||||
|
})
|
||||||
|
|
||||||
|
const max = () => store.view * 0.6
|
||||||
|
const pane = () => Math.min(height(), max())
|
||||||
|
const stacked = createMemo(() => isDesktop() && props.stacked)
|
||||||
|
const panelHeight = createMemo(() => (isDesktop() ? (stacked() ? `${pane()}px` : "100%") : opened() ? `${pane()}px` : "0px"))
|
||||||
|
const contentHeight = createMemo(() => (isDesktop() ? (stacked() ? `${pane()}px` : "100%") : `${pane()}px`))
|
||||||
|
const newTerminalKeybind = createMemo(() => command.keybindParts("terminal.new"))
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
if (typeof window === "undefined") return
|
||||||
|
|
||||||
|
const sync = () => setStore("view", window.visualViewport?.height ?? window.innerHeight)
|
||||||
|
const port = window.visualViewport
|
||||||
|
|
||||||
|
sync()
|
||||||
|
makeEventListener(window, "resize", sync)
|
||||||
|
if (port) makeEventListener(port, "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 = delays.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 = sdk().directory
|
||||||
|
if (!dir) return
|
||||||
|
if (!terminal.ready()) return
|
||||||
|
language.locale()
|
||||||
|
|
||||||
|
setTerminalHandoff(
|
||||||
|
workspaceKey(),
|
||||||
|
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 = sdk().directory
|
||||||
|
if (!dir) return []
|
||||||
|
return getTerminalHandoff(workspaceKey()) ?? []
|
||||||
|
})
|
||||||
|
|
||||||
|
const all = terminal.all
|
||||||
|
const ids = createMemo(() => all().map((pty) => pty.id))
|
||||||
|
|
||||||
|
const recoverTerminal = (key: string, id: string, clone: (id: string) => Promise<void>) => {
|
||||||
|
if (store.recovered[key]) return
|
||||||
|
setStore("recovered", key, true)
|
||||||
|
void clone(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
const terminalRecoveryKey = (pty: { id: string; title: string; titleNumber: number }) => {
|
||||||
|
return String(pty.titleNumber || pty.title || pty.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
const markTerminalConnected = (key: string, id: string, trim: (id: string) => void) => {
|
||||||
|
setStore("recovered", key, false)
|
||||||
|
trim(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleTerminalDragEnd = () => {
|
||||||
|
const activeId = terminal.active()
|
||||||
|
if (!activeId) return
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
if (terminal.active() !== activeId) return
|
||||||
|
focusTerminalById(activeId)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<aside
|
||||||
|
ref={root}
|
||||||
|
id="terminal-panel"
|
||||||
|
role="region"
|
||||||
|
aria-label={language.t("terminal.title")}
|
||||||
|
aria-hidden={!opened()}
|
||||||
|
inert={!opened()}
|
||||||
|
class="relative shrink-0 overflow-hidden bg-background-stronger"
|
||||||
|
classList={{
|
||||||
|
"w-full": !isDesktop() || stacked(),
|
||||||
|
"min-w-0 h-full flex-1": isDesktop() && opened() && !stacked(),
|
||||||
|
"w-0 h-full pointer-events-none": isDesktop() && !opened(),
|
||||||
|
"rounded-[10px] shadow-[var(--v2-elevation-raised)]": isDesktop() && newLayout(),
|
||||||
|
"transition-[height] duration-200 ease-[cubic-bezier(0.22,1,0.36,1)] will-change-[height] motion-reduce:transition-none":
|
||||||
|
!isDesktop() && !size.active(),
|
||||||
|
}}
|
||||||
|
style={{ height: panelHeight() }}
|
||||||
|
>
|
||||||
|
<div classList={{ "md:hidden": !stacked(), hidden: stacked() }} onPointerDown={() => size.start()}>
|
||||||
|
<ResizeHandle
|
||||||
|
classList={{
|
||||||
|
"-top-1": newLayout(),
|
||||||
|
}}
|
||||||
|
direction="vertical"
|
||||||
|
size={pane()}
|
||||||
|
min={100}
|
||||||
|
max={max()}
|
||||||
|
collapseThreshold={50}
|
||||||
|
onResize={(next) => {
|
||||||
|
size.touch()
|
||||||
|
layout.terminal.resize(next)
|
||||||
|
}}
|
||||||
|
onCollapse={close}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
class="absolute inset-0 flex flex-col overflow-hidden"
|
||||||
|
classList={{
|
||||||
|
"border-t border-border-weak-base": opened() && !isDesktop(),
|
||||||
|
"border-t border-border-weaker-base": opened() && stacked() && !newLayout(),
|
||||||
|
"border-l border-border-weaker-base": opened() && isDesktop() && !newLayout(),
|
||||||
|
"pointer-events-none": !opened(),
|
||||||
|
}}
|
||||||
|
style={{ height: contentHeight() }}
|
||||||
|
>
|
||||||
|
<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
|
||||||
|
sensors={[
|
||||||
|
PointerSensor.configure({
|
||||||
|
activationConstraints: [new PointerActivationConstraints.Distance({ value: 4 })],
|
||||||
|
preventActivation: (event) =>
|
||||||
|
event.target instanceof Element &&
|
||||||
|
!!event.target.closest('[data-slot="tabs-trigger-close-button"], input, [contenteditable="true"]'),
|
||||||
|
}),
|
||||||
|
]}
|
||||||
|
modifiers={[RestrictToHorizontalAxis, RestrictToElement.configure({ element: () => tabList ?? null })]}
|
||||||
|
plugins={(defaults) => [
|
||||||
|
...defaults.filter((plugin) => plugin !== Accessibility),
|
||||||
|
AutoScroller.configure({ acceleration: 8, threshold: { x: 0.05, y: 0 } }),
|
||||||
|
Feedback.configure({ dropAnimation: null }),
|
||||||
|
]}
|
||||||
|
onDragEnd={(event) => {
|
||||||
|
const source = event.operation.source
|
||||||
|
if (!event.canceled && isSortable(source) && source.initialIndex !== source.index) {
|
||||||
|
terminal.move(source.id.toString(), source.index)
|
||||||
|
}
|
||||||
|
handleTerminalDragEnd()
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div class="flex flex-col h-full">
|
||||||
|
<Tabs
|
||||||
|
variant={newLayout() ? "normal" : "alt"}
|
||||||
|
value={terminal.active()}
|
||||||
|
onChange={(id) => terminal.open(id)}
|
||||||
|
class={newLayout() ? "!h-[52px] !flex-none" : "!h-auto !flex-none"}
|
||||||
|
>
|
||||||
|
<Tabs.List ref={tabList} class={newLayout() ? undefined : "h-10 border-b border-border-weaker-base"}>
|
||||||
|
<For each={all()}>
|
||||||
|
{(pty, index) => (
|
||||||
|
<SortableTerminalTabV2 terminal={pty} index={index} newLayout={newLayout()} onClose={close} />
|
||||||
|
)}
|
||||||
|
</For>
|
||||||
|
<div class="h-full flex items-center justify-center">
|
||||||
|
<Show
|
||||||
|
when={newLayout()}
|
||||||
|
fallback={
|
||||||
|
<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>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<TooltipV2
|
||||||
|
value={
|
||||||
|
<>
|
||||||
|
{language.t("command.terminal.new")}
|
||||||
|
<Show when={newTerminalKeybind().length > 0}>
|
||||||
|
<KeybindV2 keys={newTerminalKeybind()} variant="neutral" />
|
||||||
|
</Show>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
placement="bottom"
|
||||||
|
class="flex items-center"
|
||||||
|
>
|
||||||
|
<IconButton
|
||||||
|
icon="plus-small"
|
||||||
|
variant="ghost"
|
||||||
|
iconSize="large"
|
||||||
|
onClick={terminal.new}
|
||||||
|
aria-label={language.t("command.terminal.new")}
|
||||||
|
/>
|
||||||
|
</TooltipV2>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
</Tabs.List>
|
||||||
|
</Tabs>
|
||||||
|
<div class="flex-1 min-h-0 relative">
|
||||||
|
<Show when={opened() && terminal.active()} keyed>
|
||||||
|
{(id) => {
|
||||||
|
const ops = terminal.bind()
|
||||||
|
return (
|
||||||
|
<Show when={all().find((pty) => pty.id === id)}>
|
||||||
|
{(pty) => (
|
||||||
|
<div id={`terminal-wrapper-${id}`} class="absolute inset-0">
|
||||||
|
<Terminal
|
||||||
|
pty={pty()}
|
||||||
|
autoFocus={opened()}
|
||||||
|
class="!px-[14px]"
|
||||||
|
onConnect={() => markTerminalConnected(terminalRecoveryKey(pty()), id, ops.trim)}
|
||||||
|
onCleanup={ops.update}
|
||||||
|
onConnectError={() => recoverTerminal(terminalRecoveryKey(pty()), id, ops.clone)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Show>
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</DragDropProvider>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -135,7 +135,8 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#review-panel &[data-variant="normal"][data-orientation="horizontal"] {
|
#review-panel &[data-variant="normal"][data-orientation="horizontal"],
|
||||||
|
#terminal-panel &[data-variant="normal"][data-orientation="horizontal"] {
|
||||||
background-color: var(--background-stronger);
|
background-color: var(--background-stronger);
|
||||||
|
|
||||||
[data-slot="tabs-list"] {
|
[data-slot="tabs-list"] {
|
||||||
|
|
@ -267,6 +268,51 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#terminal-panel &[data-variant="normal"][data-orientation="horizontal"] {
|
||||||
|
[data-slot="tabs-list"] {
|
||||||
|
height: 52px;
|
||||||
|
padding-right: 12px;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-slot="tabs-trigger-wrapper"] {
|
||||||
|
height: 28px;
|
||||||
|
padding-inline: 8px;
|
||||||
|
border: 0;
|
||||||
|
background-color: transparent;
|
||||||
|
box-shadow: none;
|
||||||
|
|
||||||
|
&::after {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-slot="tabs-trigger"] {
|
||||||
|
padding: 0 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:has([data-slot="tabs-trigger-close-button"]) {
|
||||||
|
padding-right: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:has([data-selected]) {
|
||||||
|
background-color: var(--v2-background-bg-layer-01);
|
||||||
|
box-shadow: inset 0 0 0 0.5px var(--v2-border-border-muted);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-slot="terminal-tab-title"] {
|
||||||
|
font-family: Inter, sans-serif;
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 440;
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 16px;
|
||||||
|
letter-spacing: -0.04px;
|
||||||
|
color: var(--v2-text-text-base);
|
||||||
|
font-variation-settings: "slnt" 0;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
&[data-variant="alt"] {
|
&[data-variant="alt"] {
|
||||||
[data-slot="tabs-list"] {
|
[data-slot="tabs-list"] {
|
||||||
padding-left: 24px;
|
padding-left: 24px;
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue