Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
137 changes: 137 additions & 0 deletions packages/app/src/components/titlebar-tab-drag.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
export type TabDragLayout = {
tabWidthById: Map<string, number>
dividerWidth: number
listLeft: number
}

export const ACTIVATION_DISTANCE = 4
export const HYSTERESIS_DEADBAND = 8
export const AUTOSCROLL_EDGE = 24
export const AUTOSCROLL_MAX_SPEED = 8
export const FLOATER_OVERSHOOT_MAX = 8

export function pointerDistance(x1: number, y1: number, x2: number, y2: number) {
const dx = x2 - x1
const dy = y2 - y1
return Math.sqrt(dx * dx + dy * dy)
}

export function captureTabDragLayout(list: HTMLElement, order: string[]) {
const tabWidthById = new Map<string, number>()
const slots = list.querySelectorAll<HTMLElement>("[data-titlebar-tab-slot]")
for (const slot of slots) {
const id = slot.dataset.tabKey
if (!id) continue
const tab = slot.querySelector<HTMLElement>("[data-titlebar-tab]")
if (!tab) continue
tabWidthById.set(id, tab.getBoundingClientRect().width)
}

let dividerWidth = 0
if (order.length >= 2) {
const secondId = order[1]
for (const slot of slots) {
if (slot.dataset.tabKey !== secondId) continue
const tab = slot.querySelector<HTMLElement>("[data-titlebar-tab]")
if (!tab) break
dividerWidth = slot.getBoundingClientRect().width - tab.getBoundingClientRect().width
break
}
}

return {
tabWidthById,
dividerWidth,
listLeft: list.getBoundingClientRect().left,
}
}

export function syncLayoutScroll(list: HTMLElement, layout: TabDragLayout) {
layout.listLeft = list.getBoundingClientRect().left
}

function slotWidthAt(order: readonly string[], index: number, layout: TabDragLayout) {
const id = order[index]
if (!id) return 0
const tabWidth = layout.tabWidthById.get(id) ?? 0
return index === 0 ? tabWidth : layout.dividerWidth + tabWidth
}

function slotLeft(order: readonly string[], index: number, layout: TabDragLayout) {
let left = layout.listLeft
for (let i = 0; i < index; i++) {
left += slotWidthAt(order, i, layout)
}
return left
}

export function insertIndexFromVirtualLayout(
pointerX: number,
order: readonly string[],
draggedId: string,
currentIndex: number,
layout: TabDragLayout,
deadband = HYSTERESIS_DEADBAND,
) {
if (order.length === 0) return 0

const others = order.filter((id) => id !== draggedId)
let target = currentIndex

if (currentIndex > 0) {
const seam = slotLeft(others, currentIndex, layout)
if (pointerX < seam - deadband) target = currentIndex - 1
}

if (target === currentIndex && currentIndex < order.length - 1) {
const seam = slotLeft(others, currentIndex + 1, layout)
if (pointerX >= seam) target = currentIndex + 1
}

return target
}

export function movePlaceholder(order: readonly string[], draggedId: string, toIndex: number) {
const fromIndex = order.indexOf(draggedId)
if (fromIndex === -1 || fromIndex === toIndex) return [...order]
const next = [...order]
next.splice(toIndex, 0, ...next.splice(fromIndex, 1))
return next
}

export function draftOrderChanged(initial: readonly string[], final: readonly string[]) {
if (initial.length === 0 || final.length === 0 || initial.length !== final.length) return false
return final.some((key, index) => key !== initial[index])
}

function easeOvershoot(overshoot: number) {
return FLOATER_OVERSHOOT_MAX * overshoot / (overshoot + FLOATER_OVERSHOOT_MAX)
}

export function clampFloaterLeft(left: number, width: number, stripLeft: number, stripRight: number) {
const stripWidth = stripRight - stripLeft
if (width >= stripWidth) return stripLeft

const maxLeft = stripRight - width
if (left > maxLeft) return maxLeft + easeOvershoot(left - maxLeft)
if (left < stripLeft) return stripLeft - easeOvershoot(stripLeft - left)

return left
}

export function autoscrollSpeed(pointerX: number, containerLeft: number, containerRight: number) {
const leftEdge = containerLeft + AUTOSCROLL_EDGE
const rightEdge = containerRight - AUTOSCROLL_EDGE

if (pointerX < leftEdge) {
const depth = (leftEdge - pointerX) / AUTOSCROLL_EDGE
return -Math.ceil(AUTOSCROLL_MAX_SPEED * Math.min(depth, 1))
}

if (pointerX > rightEdge) {
const depth = (pointerX - rightEdge) / AUTOSCROLL_EDGE
return Math.ceil(AUTOSCROLL_MAX_SPEED * Math.min(depth, 1))
}

return 0
}
140 changes: 140 additions & 0 deletions packages/app/src/components/titlebar-tab-nav.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
import { createMemo, createResource, Show } from "solid-js"
import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2"
import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon"
import { ProjectAvatar } from "@opencode-ai/ui/v2/project-avatar-v2"
import { getProjectAvatarVariant, type LocalProject } from "@/context/layout"
import { useGlobal } from "@/context/global"
import { ServerConnection } from "@/context/server"
import { displayName, getProjectAvatarSource, projectForSession } from "@/pages/layout/helpers"
import { useSessionTabAvatarState } from "@/pages/layout/project-avatar-state"

function ProjectTabAvatar(props: {
project?: LocalProject
directory: string
sessionId: string
activeServer: boolean
}) {
const directory = () => props.directory
const sessionId = () => props.sessionId
const state = useSessionTabAvatarState(directory, sessionId, () => props.activeServer)
return (
<ProjectAvatar
fallback={displayName(props.project ?? { worktree: props.directory })}
src={getProjectAvatarSource(props.project?.id, props.project?.icon)}
variant={getProjectAvatarVariant(props.project?.icon?.color)}
unread={state.unread()}
loading={state.loading()}
/>
)
}

export function TabNavItem(props: {
ref?: HTMLDivElement
href: string
server: ServerConnection.Key
directory: string
sessionId?: string
onClose: () => void
onNavigate: () => void
active?: boolean
activeServer: boolean
forceTruncate?: boolean
suppressNavigation?: () => boolean
dragging?: boolean
pressed?: boolean
hidden?: boolean
}) {
const closeTab = (event: MouseEvent) => {
event.preventDefault()
event.stopPropagation()
props.onClose()
}
const global = useGlobal()
const serverCtx = createMemo(() => {
const conn = global.servers.list().find((item) => ServerConnection.key(item) === props.server)
if (conn) return global.createServerCtx(conn)
})
const dirSyncCtx = createMemo(() => serverCtx()?.sync.createDirSyncContext(props.directory))

const [session] = createResource(
() => {
const ctx = dirSyncCtx()
if (!ctx || !props.sessionId) return
return [props.sessionId, ctx] as const
},
async ([sessionId, dirSyncCtx]) => {
await dirSyncCtx.session.sync(sessionId).catch(() => {})
return dirSyncCtx.session.get(sessionId)
},
{ initialValue: props.sessionId ? dirSyncCtx()?.session.get(props.sessionId) : undefined },
)

return (
<div
ref={props.ref}
data-titlebar-tab
class="group relative flex h-7 min-w-24 max-w-60 flex-row items-center gap-1.5 overflow-hidden whitespace-nowrap rounded-[6px] bg-[var(--tab-bg)] px-1.5 [--tab-bg:var(--v2-background-bg-deep)] hover:[--tab-bg:var(--v2-background-bg-layer-02)] data-[active='true']:[--tab-bg:var(--v2-background-bg-layer-02)] data-[dragging='true']:[--tab-bg:var(--v2-background-bg-layer-02)] data-[pressed='true']:[--tab-bg:var(--v2-background-bg-layer-02)]"
classList={{ invisible: props.hidden }}
data-active={props.active}
data-dragging={props.dragging}
data-pressed={props.pressed}
onMouseDown={(event) => {
if (event.button !== 1) return
closeTab(event)
}}
>
<Show when={session.latest}>
{(session) => {
const project = createMemo(() => projectForSession(session(), serverCtx()?.projects.list() ?? []))

return (
<a
href={props.href}
draggable={false}
onDragStart={(event) => {
event.preventDefault()
event.stopPropagation()
}}
onClick={(event) => {
event.preventDefault()
if (props.suppressNavigation?.()) return
props.onNavigate()
}}
class="flex h-full min-w-0 flex-1 flex-row items-center gap-1.5 text-[13px] font-medium text-v2-text-text-faint group-data-[active='true']:text-v2-text-text-base [-webkit-user-drag:none]"
>
<span data-slot="project-avatar-slot">
<ProjectTabAvatar
project={project()}
directory={props.directory}
sessionId={session().id}
activeServer={props.activeServer}
/>
</span>
<span class="min-w-0 flex-1">{session().title}</span>
</a>
)
}}
</Show>

<div
class="absolute not-group-hover:not-group-data-[active=true]:not-data-[truncate=true]:left-52 group-hover:right-0 group-data-[active=true]:right-0 data-[truncate=true]:right-0 inset-y-0 flex flex-row items-center pr-1 py-1 w-8 pl-2"
data-truncate={props.forceTruncate}
>
<div
class="absolute inset-0 rounded-r-[6px] bg-(image:--inactive-bg) group-hover:bg-(image:--active-bg) group-data-[active=true]:bg-(image:--active-bg)"
style={{
"--inactive-bg": "linear-gradient(to right, transparent 0%, var(--tab-bg) 80%)",
"--active-bg": "linear-gradient(90deg, transparent 0%, var(--tab-bg) 25%)",
}}
/>
<IconButtonV2
size="small"
variant="ghost-muted"
class="opacity-0 group-hover:opacity-100 group-data-[active='true']:opacity-100 z-10"
onClick={closeTab}
icon={<IconV2 name="xmark-small" />}
/>
</div>
</div>
)
}
Loading
Loading