feat(app): draggable tabs (#31364)
Co-authored-by: LukeParkerDev <10430890+Hona@users.noreply.github.com>
This commit is contained in:
parent
5d4f160d64
commit
d5aa5ff8fb
12 changed files with 1504 additions and 384 deletions
135
packages/app/e2e/regression/cross-server-tab-close.spec.ts
Normal file
135
packages/app/e2e/regression/cross-server-tab-close.spec.ts
Normal file
|
|
@ -0,0 +1,135 @@
|
||||||
|
import { expect, test, type Page, type Route } from "@playwright/test"
|
||||||
|
import { base64Encode } from "@opencode-ai/core/util/encode"
|
||||||
|
|
||||||
|
const serverA = "http://127.0.0.1:4096"
|
||||||
|
const serverB = "http://127.0.0.1:4097"
|
||||||
|
const sessionA = session("ses_server_a", "C:/server-a", "Server A session")
|
||||||
|
const sessionB = session("ses_server_b", "/home/server-b", "Server B session")
|
||||||
|
|
||||||
|
test("closing the active server's last tab opens the remaining server tab", async ({ page }) => {
|
||||||
|
const requests: string[] = []
|
||||||
|
await mockServers(page, requests)
|
||||||
|
await page.addInitScript(
|
||||||
|
({ serverB, sessionA, sessionB }) => {
|
||||||
|
localStorage.setItem("settings.v3", JSON.stringify({ general: { newLayoutDesigns: true } }))
|
||||||
|
localStorage.setItem("opencode.global.dat:server", JSON.stringify({ list: [serverB] }))
|
||||||
|
localStorage.setItem(
|
||||||
|
"opencode.global.dat:tabs",
|
||||||
|
JSON.stringify([
|
||||||
|
{ type: "session", server: "http://127.0.0.1:4096", sessionId: sessionA },
|
||||||
|
{ type: "session", server: serverB, sessionId: sessionB },
|
||||||
|
]),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
{ serverB, sessionA: sessionA.id, sessionB: sessionB.id },
|
||||||
|
)
|
||||||
|
|
||||||
|
const hrefA = `/server/${base64Encode(serverA)}/session/${sessionA.id}`
|
||||||
|
const hrefB = `/server/${base64Encode(serverB)}/session/${sessionB.id}`
|
||||||
|
await page.goto(hrefA)
|
||||||
|
await expect(page.getByText(sessionA.title).first()).toBeVisible()
|
||||||
|
|
||||||
|
const tabA = page.locator(`[data-titlebar-tab-slot]:has(a[href="${hrefA}"])`)
|
||||||
|
await tabA.locator('[data-slot="tab-close"] button').click()
|
||||||
|
|
||||||
|
await expect(page).toHaveURL(new RegExp(`${hrefB.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}$`))
|
||||||
|
await expect.poll(() => requests.some((url) => url.startsWith(`${serverB}/session/${sessionB.id}`))).toBe(true)
|
||||||
|
await expect(page.getByText(sessionB.title).first()).toBeVisible()
|
||||||
|
const sessionBRequests = requests.filter((url) => url.includes(`/session/${sessionB.id}`))
|
||||||
|
expect(sessionBRequests.every((url) => url.startsWith(serverB))).toBe(true)
|
||||||
|
expect(
|
||||||
|
requests.some((request) => {
|
||||||
|
const url = new URL(request)
|
||||||
|
return url.origin === serverB && url.searchParams.get("directory") === sessionB.directory
|
||||||
|
}),
|
||||||
|
).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("legacy session routes preserve an existing tab's server", async ({ page }) => {
|
||||||
|
await mockServers(page, [])
|
||||||
|
await page.addInitScript(
|
||||||
|
({ serverB, sessionB }) => {
|
||||||
|
localStorage.setItem("settings.v3", JSON.stringify({ general: { newLayoutDesigns: true } }))
|
||||||
|
localStorage.setItem("opencode.global.dat:server", JSON.stringify({ list: [serverB] }))
|
||||||
|
localStorage.setItem(
|
||||||
|
"opencode.global.dat:tabs",
|
||||||
|
JSON.stringify([{ type: "session", server: serverB, sessionId: sessionB }]),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
{ serverB, sessionB: sessionB.id },
|
||||||
|
)
|
||||||
|
|
||||||
|
const hrefB = `/server/${base64Encode(serverB)}/session/${sessionB.id}`
|
||||||
|
await page.goto(`/${base64Encode(sessionB.directory)}/session/${sessionB.id}`)
|
||||||
|
await expect(page).toHaveURL(new RegExp(`${hrefB.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}$`))
|
||||||
|
})
|
||||||
|
|
||||||
|
function session(id: string, directory: string, title: string) {
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
slug: id,
|
||||||
|
projectID: `project-${id}`,
|
||||||
|
directory,
|
||||||
|
title,
|
||||||
|
version: "dev",
|
||||||
|
time: { created: 1, updated: 1 },
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function mockServers(page: Page, requests: string[]) {
|
||||||
|
await page.route("**/*", async (route) => {
|
||||||
|
const url = new URL(route.request().url())
|
||||||
|
if (url.origin !== serverA && url.origin !== serverB) return route.fallback()
|
||||||
|
requests.push(url.toString())
|
||||||
|
const current = url.origin === serverA ? sessionA : sessionB
|
||||||
|
const directory = url.searchParams.get("directory")
|
||||||
|
if (directory && directory !== current.directory) return json(route, { name: "InvalidDirectory" }, 500)
|
||||||
|
if (url.pathname === "/global/event" || url.pathname === "/event") return sse(route)
|
||||||
|
if (url.pathname === "/global/health") return json(route, { healthy: true })
|
||||||
|
if (url.pathname === "/session") return json(route, [current])
|
||||||
|
if (url.pathname === `/session/${current.id}`) return json(route, current)
|
||||||
|
if (/^\/session\/[^/]+$/.test(url.pathname)) return json(route, { name: "NotFoundError" }, 404)
|
||||||
|
if (url.pathname === `/session/${current.id}/message`) return json(route, [])
|
||||||
|
if (/^\/session\/[^/]+\/(children|todo|diff)$/.test(url.pathname)) return json(route, [])
|
||||||
|
if (["/skill", "/command", "/lsp", "/formatter", "/permission", "/question", "/vcs/diff"].includes(url.pathname))
|
||||||
|
return json(route, [])
|
||||||
|
if (["/global/config", "/config", "/provider/auth", "/mcp", "/session/status"].includes(url.pathname))
|
||||||
|
return json(route, {})
|
||||||
|
if (url.pathname === "/provider")
|
||||||
|
return json(route, { all: [], connected: [], default: { providerID: "", modelID: "" } })
|
||||||
|
if (url.pathname === "/agent") return json(route, [{ name: "build", mode: "primary" }])
|
||||||
|
if (url.pathname === "/project" || url.pathname === "/project/current") {
|
||||||
|
const project = {
|
||||||
|
id: current.projectID,
|
||||||
|
worktree: current.directory,
|
||||||
|
vcs: "git",
|
||||||
|
time: { created: 1, updated: 1 },
|
||||||
|
sandboxes: [],
|
||||||
|
}
|
||||||
|
return json(route, url.pathname === "/project" ? [project] : project)
|
||||||
|
}
|
||||||
|
if (url.pathname === "/path")
|
||||||
|
return json(route, {
|
||||||
|
state: current.directory,
|
||||||
|
config: current.directory,
|
||||||
|
worktree: current.directory,
|
||||||
|
directory: current.directory,
|
||||||
|
home: current.directory,
|
||||||
|
})
|
||||||
|
if (url.pathname === "/vcs") return json(route, { branch: "main", default_branch: "main" })
|
||||||
|
return json(route, {})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function json(route: Route, body: unknown, status = 200) {
|
||||||
|
return route.fulfill({
|
||||||
|
status,
|
||||||
|
contentType: "application/json",
|
||||||
|
headers: { "access-control-allow-origin": "*" },
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function sse(route: Route) {
|
||||||
|
return route.fulfill({ status: 200, contentType: "text/event-stream", body: ": ok\n\n" })
|
||||||
|
}
|
||||||
|
|
@ -51,7 +51,13 @@ import LegacyLayout from "@/pages/layout"
|
||||||
import NewLayout from "@/pages/layout-new"
|
import NewLayout from "@/pages/layout-new"
|
||||||
import { ErrorPage } from "./pages/error"
|
import { ErrorPage } from "./pages/error"
|
||||||
import { useCheckServerHealth } from "./utils/server-health"
|
import { useCheckServerHealth } from "./utils/server-health"
|
||||||
import { legacySessionHref, requireServerKey, selectSessionLineage, sessionHref } from "./utils/session-route"
|
import {
|
||||||
|
legacySessionHref,
|
||||||
|
legacySessionServer,
|
||||||
|
requireServerKey,
|
||||||
|
selectSessionLineage,
|
||||||
|
sessionHref,
|
||||||
|
} from "./utils/session-route"
|
||||||
|
|
||||||
import Session from "@/pages/session"
|
import Session from "@/pages/session"
|
||||||
import { NewHome, LegacyHome } from "@/pages/home"
|
import { NewHome, LegacyHome } from "@/pages/home"
|
||||||
|
|
@ -67,7 +73,15 @@ const SessionRoute = () => {
|
||||||
const tabs = useTabs()
|
const tabs = useTabs()
|
||||||
|
|
||||||
if (params.id && settings.general.newLayoutDesigns()) {
|
if (params.id && settings.general.newLayoutDesigns()) {
|
||||||
return <Navigate href={sessionHref(server.key, params.id)} />
|
const sessionID = params.id
|
||||||
|
return (
|
||||||
|
<Show when={tabs.ready()}>
|
||||||
|
{(_) => {
|
||||||
|
const persisted = tabs.store.filter((item) => item.type === "session")
|
||||||
|
return <Navigate href={sessionHref(legacySessionServer(persisted, sessionID, server.key), sessionID)} />
|
||||||
|
}}
|
||||||
|
</Show>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// When the new layout is enabled, the legacy new-session route (/:dir/session with no id)
|
// When the new layout is enabled, the legacy new-session route (/:dir/session with no id)
|
||||||
|
|
|
||||||
119
packages/app/src/components/titlebar-tab-drag.test.ts
Normal file
119
packages/app/src/components/titlebar-tab-drag.test.ts
Normal file
|
|
@ -0,0 +1,119 @@
|
||||||
|
import { describe, expect, test } from "bun:test"
|
||||||
|
import { captureTabDragLayout, insertIndexFromVirtualLayout } from "./titlebar-tab-drag"
|
||||||
|
import {
|
||||||
|
canOpenTabRename,
|
||||||
|
captureTabPointerDown,
|
||||||
|
canStartTabDrag,
|
||||||
|
createTabDragPreview,
|
||||||
|
forwardTabRef,
|
||||||
|
isPrimaryPointerPressed,
|
||||||
|
isTabCloseTarget,
|
||||||
|
} from "./titlebar-tab-gesture"
|
||||||
|
|
||||||
|
describe("titlebar tab drag", () => {
|
||||||
|
const layout = {
|
||||||
|
listLeft: 100,
|
||||||
|
dividerWidth: 13,
|
||||||
|
tabWidthById: new Map([
|
||||||
|
["a", 40],
|
||||||
|
["b", 40],
|
||||||
|
["c", 40],
|
||||||
|
["d", 40],
|
||||||
|
]),
|
||||||
|
}
|
||||||
|
|
||||||
|
test("moves across multiple tabs from one pointer update", () => {
|
||||||
|
expect(insertIndexFromVirtualLayout(260, ["a", "b", "c", "d"], "a", 0, layout)).toBe(3)
|
||||||
|
expect(insertIndexFromVirtualLayout(90, ["a", "b", "c", "d"], "d", 3, layout)).toBe(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("keeps the current index inside the left hysteresis deadband", () => {
|
||||||
|
expect(insertIndexFromVirtualLayout(146, ["a", "b", "c", "d"], "b", 1, layout)).toBe(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("includes slot margins in captured divider width", () => {
|
||||||
|
const list = document.createElement("div")
|
||||||
|
const first = document.createElement("div")
|
||||||
|
const second = document.createElement("div")
|
||||||
|
const firstTab = document.createElement("div")
|
||||||
|
const secondTab = document.createElement("div")
|
||||||
|
first.dataset.titlebarTabSlot = ""
|
||||||
|
first.dataset.tabKey = "a"
|
||||||
|
second.dataset.titlebarTabSlot = ""
|
||||||
|
second.dataset.tabKey = "b"
|
||||||
|
second.style.marginLeft = "6px"
|
||||||
|
firstTab.dataset.titlebarTab = ""
|
||||||
|
secondTab.dataset.titlebarTab = ""
|
||||||
|
first.append(firstTab)
|
||||||
|
second.append(secondTab)
|
||||||
|
list.append(first, second)
|
||||||
|
document.body.append(list)
|
||||||
|
firstTab.getBoundingClientRect = () => ({ width: 40 }) as DOMRect
|
||||||
|
secondTab.getBoundingClientRect = () => ({ width: 40 }) as DOMRect
|
||||||
|
second.getBoundingClientRect = () => ({ width: 47 }) as DOMRect
|
||||||
|
list.getBoundingClientRect = () => ({ left: 100 }) as DOMRect
|
||||||
|
|
||||||
|
expect(captureTabDragLayout(list, ["a", "b"]).dividerWidth).toBe(13)
|
||||||
|
list.remove()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("titlebar tab gestures", () => {
|
||||||
|
test("excludes close controls from tab gestures", () => {
|
||||||
|
const close = document.createElement("div")
|
||||||
|
const button = document.createElement("button")
|
||||||
|
const link = document.createElement("a")
|
||||||
|
close.dataset.slot = "tab-close"
|
||||||
|
close.append(button)
|
||||||
|
expect(isTabCloseTarget(close)).toBe(true)
|
||||||
|
expect(isTabCloseTarget(button)).toBe(true)
|
||||||
|
expect(isTabCloseTarget(link)).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("forwards component refs", () => {
|
||||||
|
const element = document.createElement("div")
|
||||||
|
let received: HTMLDivElement | undefined
|
||||||
|
forwardTabRef((value) => (received = value), element)
|
||||||
|
expect(received).toBe(element)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("does not reopen rename while a save is pending", () => {
|
||||||
|
expect(canOpenTabRename(false, false, false)).toBe(true)
|
||||||
|
expect(canOpenTabRename(false, false, true)).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("keeps the rendered tab content in the drag preview", () => {
|
||||||
|
const tab = document.createElement("div")
|
||||||
|
tab.innerHTML = '<span data-slot="project-avatar-slot"></span><span data-slot="tab-title">Session</span>'
|
||||||
|
const preview = createTabDragPreview(tab)
|
||||||
|
expect(preview.querySelector('[data-slot="project-avatar-slot"]')).not.toBeNull()
|
||||||
|
expect(preview.querySelector('[data-slot="tab-title"]')?.textContent).toBe("Session")
|
||||||
|
})
|
||||||
|
|
||||||
|
test("captures the grab offset before navigation scrolls the tab", () => {
|
||||||
|
const tab = document.createElement("div")
|
||||||
|
tab.getBoundingClientRect = () => ({ left: 80, top: 10, width: 120 }) as DOMRect
|
||||||
|
|
||||||
|
expect(captureTabPointerDown(tab, 100, 20)).toEqual({
|
||||||
|
startX: 100,
|
||||||
|
startY: 20,
|
||||||
|
grabOffsetX: 20,
|
||||||
|
grabOffsetY: 10,
|
||||||
|
width: 120,
|
||||||
|
element: tab,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test("detects when the primary pointer button was released outside the window", () => {
|
||||||
|
expect(isPrimaryPointerPressed(1)).toBe(true)
|
||||||
|
expect(isPrimaryPointerPressed(3)).toBe(true)
|
||||||
|
expect(isPrimaryPointerPressed(0)).toBe(false)
|
||||||
|
expect(isPrimaryPointerPressed(2)).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("preserves native panning for touch pointers", () => {
|
||||||
|
expect(canStartTabDrag("mouse")).toBe(true)
|
||||||
|
expect(canStartTabDrag("pen")).toBe(true)
|
||||||
|
expect(canStartTabDrag("touch")).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
135
packages/app/src/components/titlebar-tab-drag.ts
Normal file
135
packages/app/src/components/titlebar-tab-drag.ts
Normal file
|
|
@ -0,0 +1,135 @@
|
||||||
|
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
|
||||||
|
const style = getComputedStyle(slot)
|
||||||
|
dividerWidth =
|
||||||
|
slot.getBoundingClientRect().width -
|
||||||
|
tab.getBoundingClientRect().width +
|
||||||
|
(Number.parseFloat(style.marginLeft) || 0) +
|
||||||
|
(Number.parseFloat(style.marginRight) || 0)
|
||||||
|
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
|
||||||
|
|
||||||
|
while (target > 0 && pointerX < slotLeft(others, target, layout) - deadband) target--
|
||||||
|
while (target < order.length - 1 && pointerX >= slotLeft(others, target + 1, layout)) target++
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
37
packages/app/src/components/titlebar-tab-gesture.ts
Normal file
37
packages/app/src/components/titlebar-tab-gesture.ts
Normal file
|
|
@ -0,0 +1,37 @@
|
||||||
|
import type { Ref } from "solid-js"
|
||||||
|
|
||||||
|
export function isTabCloseTarget(target: EventTarget | null) {
|
||||||
|
return target instanceof Element && !!target.closest('[data-slot="tab-close"]')
|
||||||
|
}
|
||||||
|
|
||||||
|
export function canStartTabDrag(pointerType: string) {
|
||||||
|
return pointerType !== "touch"
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isPrimaryPointerPressed(buttons: number) {
|
||||||
|
return (buttons & 1) !== 0
|
||||||
|
}
|
||||||
|
|
||||||
|
export function captureTabPointerDown(element: HTMLDivElement, clientX: number, clientY: number) {
|
||||||
|
const rect = element.getBoundingClientRect()
|
||||||
|
return {
|
||||||
|
startX: clientX,
|
||||||
|
startY: clientY,
|
||||||
|
grabOffsetX: clientX - rect.left,
|
||||||
|
grabOffsetY: clientY - rect.top,
|
||||||
|
width: rect.width,
|
||||||
|
element,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function forwardTabRef(ref: Ref<HTMLDivElement> | undefined, element: HTMLDivElement) {
|
||||||
|
if (typeof ref === "function") ref(element)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function canOpenTabRename(dragging: boolean | undefined, editing: boolean, committing: boolean) {
|
||||||
|
return !dragging && !editing && !committing
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createTabDragPreview(element: HTMLDivElement) {
|
||||||
|
return element.cloneNode(true) as HTMLDivElement
|
||||||
|
}
|
||||||
68
packages/app/src/components/titlebar-tab-nav.css
Normal file
68
packages/app/src/components/titlebar-tab-nav.css
Normal file
|
|
@ -0,0 +1,68 @@
|
||||||
|
[data-titlebar-tab] [data-slot="tab-close"] {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
right: 0;
|
||||||
|
display: flex;
|
||||||
|
height: 28px;
|
||||||
|
width: 28px;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-titlebar-tab] [data-slot="tab-close"]::before {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
height: 28px;
|
||||||
|
width: 16px;
|
||||||
|
pointer-events: none;
|
||||||
|
background: linear-gradient(to right, transparent, var(--tab-bg));
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-titlebar-tab][data-title-overflow="true"]:not([data-editing="true"]) [data-slot="tab-close"]::before {
|
||||||
|
display: block;
|
||||||
|
right: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-titlebar-tab][data-title-overflow="true"]:hover:not([data-editing="true"]) [data-slot="tab-close"]::before,
|
||||||
|
[data-titlebar-tab][data-title-overflow="true"][data-active="true"]:not([data-editing="true"]) [data-slot="tab-close"]::before {
|
||||||
|
right: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-titlebar-tab] [data-slot="tab-link"] {
|
||||||
|
padding-right: 22px;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-titlebar-tab][data-title-overflow="true"]:not(:hover):not([data-active="true"]):not([data-editing="true"])
|
||||||
|
[data-slot="tab-link"] {
|
||||||
|
padding-right: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-titlebar-tab][data-editing="true"] [data-slot="tab-link"] {
|
||||||
|
padding-right: 22px;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-titlebar-tab] [data-slot="tab-title"] {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-titlebar-tab][data-editing="true"] [data-slot="tab-title"] {
|
||||||
|
min-width: 0;
|
||||||
|
max-width: 100%;
|
||||||
|
flex: 1 1 auto;
|
||||||
|
overflow-x: auto;
|
||||||
|
overflow-y: hidden;
|
||||||
|
white-space: nowrap;
|
||||||
|
scrollbar-width: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-titlebar-tab][data-editing="true"] [data-slot="tab-title"]::-webkit-scrollbar {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-titlebar-tab]:hover [data-slot="tab-close"],
|
||||||
|
[data-titlebar-tab][data-active="true"] [data-slot="tab-close"],
|
||||||
|
[data-titlebar-tab][data-editing="true"] [data-slot="tab-close"] {
|
||||||
|
background: var(--tab-bg);
|
||||||
|
}
|
||||||
344
packages/app/src/components/titlebar-tab-nav.tsx
Normal file
344
packages/app/src/components/titlebar-tab-nav.tsx
Normal file
|
|
@ -0,0 +1,344 @@
|
||||||
|
import { createEffect, createMemo, createSignal, onCleanup, Show, type Ref } from "solid-js"
|
||||||
|
import { makeEventListener } from "@solid-primitives/event-listener"
|
||||||
|
import { createResizeObserver } from "@solid-primitives/resize-observer"
|
||||||
|
import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2"
|
||||||
|
import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon"
|
||||||
|
import { useGlobal } from "@/context/global"
|
||||||
|
import { useLanguage } from "@/context/language"
|
||||||
|
import { ServerConnection } from "@/context/server"
|
||||||
|
import { projectForSession } from "@/pages/layout/helpers"
|
||||||
|
import { SessionTabAvatar } from "@/pages/layout/session-tab-avatar"
|
||||||
|
import { showToast } from "@/utils/toast"
|
||||||
|
import type { Session } from "@opencode-ai/sdk/v2"
|
||||||
|
import { canOpenTabRename, forwardTabRef } from "./titlebar-tab-gesture"
|
||||||
|
import "./titlebar-tab-nav.css"
|
||||||
|
|
||||||
|
export function TabNavItem(props: {
|
||||||
|
ref?: Ref<HTMLDivElement>
|
||||||
|
href: string
|
||||||
|
server: ServerConnection.Key
|
||||||
|
session: () => Session | undefined
|
||||||
|
onTitleChange?: (title: string) => void
|
||||||
|
onTitleChangeFailed?: (title: string) => void
|
||||||
|
onClose: () => void
|
||||||
|
onNavigate: () => void
|
||||||
|
active?: boolean
|
||||||
|
activeServer: boolean
|
||||||
|
forceTruncate?: boolean
|
||||||
|
suppressNavigation?: () => boolean
|
||||||
|
dragging?: boolean
|
||||||
|
pressed?: boolean
|
||||||
|
hidden?: boolean
|
||||||
|
}) {
|
||||||
|
const language = useLanguage()
|
||||||
|
const [editing, setEditing] = createSignal(false)
|
||||||
|
const [titleOverflowing, setTitleOverflowing] = createSignal(false)
|
||||||
|
let tabRoot!: HTMLDivElement
|
||||||
|
let titleEl!: HTMLSpanElement
|
||||||
|
let committing = false
|
||||||
|
let measureFrame: number | undefined
|
||||||
|
|
||||||
|
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.ensureServerCtx(conn)
|
||||||
|
})
|
||||||
|
const project = createMemo(() => {
|
||||||
|
const session = props.session()
|
||||||
|
if (!session) return
|
||||||
|
return projectForSession(session, serverCtx()?.projects.list() ?? [])
|
||||||
|
})
|
||||||
|
|
||||||
|
const measureTitleOverflow = () => {
|
||||||
|
if (!titleEl || editing()) {
|
||||||
|
setTitleOverflowing(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setTitleOverflowing(titleEl.scrollWidth > titleEl.clientWidth)
|
||||||
|
}
|
||||||
|
|
||||||
|
const scheduleTitleOverflow = () => {
|
||||||
|
if (measureFrame !== undefined) return
|
||||||
|
measureFrame = requestAnimationFrame(() => {
|
||||||
|
measureFrame = undefined
|
||||||
|
measureTitleOverflow()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
createEffect(() => {
|
||||||
|
props.session()?.title
|
||||||
|
props.forceTruncate
|
||||||
|
editing()
|
||||||
|
scheduleTitleOverflow()
|
||||||
|
})
|
||||||
|
|
||||||
|
createResizeObserver(() => tabRoot, scheduleTitleOverflow)
|
||||||
|
onCleanup(() => {
|
||||||
|
if (measureFrame !== undefined) cancelAnimationFrame(measureFrame)
|
||||||
|
})
|
||||||
|
|
||||||
|
const selectTitle = () => {
|
||||||
|
const range = document.createRange()
|
||||||
|
range.selectNodeContents(titleEl)
|
||||||
|
const selection = window.getSelection()
|
||||||
|
selection?.removeAllRanges()
|
||||||
|
selection?.addRange(range)
|
||||||
|
}
|
||||||
|
|
||||||
|
const rename = async (title: string) => {
|
||||||
|
const ctx = serverCtx()
|
||||||
|
const session = props.session()
|
||||||
|
if (!ctx || !session) return
|
||||||
|
const client = ctx.sdk.createClient({ directory: session.directory, throwOnError: true })
|
||||||
|
await client.session.update({ sessionID: session.id, title })
|
||||||
|
}
|
||||||
|
|
||||||
|
const closeRename = async (save: boolean) => {
|
||||||
|
if (committing || !editing()) return
|
||||||
|
committing = true
|
||||||
|
|
||||||
|
const original = props.session()?.title ?? ""
|
||||||
|
const next = (titleEl.textContent ?? "").trim()
|
||||||
|
|
||||||
|
titleEl.scrollLeft = 0
|
||||||
|
if (save && next && next !== original) props.onTitleChange?.(next)
|
||||||
|
setEditing(false)
|
||||||
|
|
||||||
|
if (!save || !next || next === original) {
|
||||||
|
committing = false
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await rename(next)
|
||||||
|
} catch (err) {
|
||||||
|
props.onTitleChangeFailed?.(original)
|
||||||
|
showToast({
|
||||||
|
title: language.t("common.requestFailed"),
|
||||||
|
description: err instanceof Error ? err.message : undefined,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
committing = false
|
||||||
|
}
|
||||||
|
|
||||||
|
createEffect(() => {
|
||||||
|
if (editing()) return
|
||||||
|
if (!titleEl) return
|
||||||
|
const title = props.session()?.title
|
||||||
|
if (title === undefined) return
|
||||||
|
titleEl.textContent = title
|
||||||
|
})
|
||||||
|
|
||||||
|
const openRename = (event: MouseEvent) => {
|
||||||
|
event.preventDefault()
|
||||||
|
event.stopPropagation()
|
||||||
|
if (!canOpenTabRename(props.dragging, editing(), committing)) return
|
||||||
|
const session = props.session()
|
||||||
|
if (!session) return
|
||||||
|
titleEl.textContent = session.title
|
||||||
|
setEditing(true)
|
||||||
|
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
titleEl.focus()
|
||||||
|
selectTitle()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
createEffect(() => {
|
||||||
|
if (!editing()) return
|
||||||
|
|
||||||
|
const cleanup = makeEventListener(
|
||||||
|
document,
|
||||||
|
"pointerdown",
|
||||||
|
(event) => {
|
||||||
|
const target = event.target
|
||||||
|
if (!(target instanceof Node)) return
|
||||||
|
if (tabRoot.contains(target)) return
|
||||||
|
void closeRename(true)
|
||||||
|
},
|
||||||
|
{ capture: true },
|
||||||
|
)
|
||||||
|
|
||||||
|
onCleanup(cleanup)
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={(el) => {
|
||||||
|
tabRoot = el
|
||||||
|
forwardTabRef(props.ref, el)
|
||||||
|
}}
|
||||||
|
data-titlebar-tab
|
||||||
|
data-title-overflow={titleOverflowing()}
|
||||||
|
data-editing={editing()}
|
||||||
|
class="group relative flex h-7 min-w-24 max-w-60 select-none 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)] data-[editing='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={props.session()}>
|
||||||
|
{(session) => {
|
||||||
|
return (
|
||||||
|
<a
|
||||||
|
data-slot="tab-link"
|
||||||
|
href={props.href}
|
||||||
|
draggable={false}
|
||||||
|
onDragStart={(event) => {
|
||||||
|
event.preventDefault()
|
||||||
|
event.stopPropagation()
|
||||||
|
}}
|
||||||
|
onClick={(event) => {
|
||||||
|
event.preventDefault()
|
||||||
|
if (editing()) return
|
||||||
|
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 group-data-[editing='true']:text-v2-text-text-base [-webkit-user-drag:none]"
|
||||||
|
>
|
||||||
|
<span data-slot="project-avatar-slot">
|
||||||
|
<SessionTabAvatar
|
||||||
|
project={project()}
|
||||||
|
directory={session().directory}
|
||||||
|
sessionId={session().id}
|
||||||
|
activeServer={props.activeServer}
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
ref={(el) => {
|
||||||
|
titleEl = el
|
||||||
|
titleEl.textContent = session().title
|
||||||
|
}}
|
||||||
|
data-slot="tab-title"
|
||||||
|
class="min-w-0 flex-1 outline-none leading-4"
|
||||||
|
classList={{
|
||||||
|
"overflow-hidden text-clip whitespace-nowrap": !editing(),
|
||||||
|
"select-text": editing(),
|
||||||
|
}}
|
||||||
|
contenteditable={editing() ? true : undefined}
|
||||||
|
onDblClick={openRename}
|
||||||
|
onKeyDown={(event) => {
|
||||||
|
event.stopPropagation()
|
||||||
|
if (event.key === "Enter") {
|
||||||
|
event.preventDefault()
|
||||||
|
void closeRename(true)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (event.key !== "Escape") return
|
||||||
|
event.preventDefault()
|
||||||
|
titleEl.textContent = session().title
|
||||||
|
void closeRename(false)
|
||||||
|
}}
|
||||||
|
onBlur={() => void closeRename(true)}
|
||||||
|
onPointerDown={(event) => {
|
||||||
|
if (!editing()) return
|
||||||
|
event.stopPropagation()
|
||||||
|
}}
|
||||||
|
onClick={(event) => {
|
||||||
|
if (!editing()) return
|
||||||
|
event.preventDefault()
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</a>
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
</Show>
|
||||||
|
|
||||||
|
<div data-slot="tab-close">
|
||||||
|
<IconButtonV2
|
||||||
|
size="small"
|
||||||
|
variant="ghost-muted"
|
||||||
|
class="relative z-10 opacity-0 group-hover:opacity-100 group-data-[active=true]:opacity-100 group-data-[editing=true]:opacity-100"
|
||||||
|
onPointerDown={(event) => {
|
||||||
|
event.preventDefault()
|
||||||
|
event.stopPropagation()
|
||||||
|
}}
|
||||||
|
onClick={closeTab}
|
||||||
|
icon={<IconV2 name="xmark-small" />}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DraftTabItem(props: {
|
||||||
|
ref?: Ref<HTMLDivElement>
|
||||||
|
href: string
|
||||||
|
title: string
|
||||||
|
active?: boolean
|
||||||
|
onNavigate: () => void
|
||||||
|
onClose: () => void
|
||||||
|
suppressNavigation?: () => boolean
|
||||||
|
dragging?: boolean
|
||||||
|
pressed?: boolean
|
||||||
|
hidden?: boolean
|
||||||
|
}) {
|
||||||
|
const closeTab = (event: MouseEvent) => {
|
||||||
|
event.preventDefault()
|
||||||
|
event.stopPropagation()
|
||||||
|
props.onClose()
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={(el) => forwardTabRef(props.ref, el)}
|
||||||
|
data-titlebar-tab
|
||||||
|
data-active={props.active}
|
||||||
|
data-dragging={props.dragging}
|
||||||
|
data-pressed={props.pressed}
|
||||||
|
class="group relative shrink-0 flex h-7 max-w-60 flex-row items-center gap-1.5 overflow-hidden rounded-[6px] bg-[var(--tab-bg)] pl-1.5 pr-8 whitespace-nowrap [--tab-bg:var(--v2-background-bg-deep)] hover:[--tab-bg:var(--v2-background-bg-layer-02)] data-[active='true']:[--tab-bg:var(--v2-overlay-simple-overlay-pressed)] data-[dragging='true']:[--tab-bg:var(--v2-background-bg-layer-02)] data-[pressed='true']:[--tab-bg:var(--v2-background-bg-layer-02)] focus-within:outline focus-within:outline-2 focus-within:outline-offset-2 focus-within:outline-[var(--v2-border-border-focus)]"
|
||||||
|
classList={{ invisible: props.hidden }}
|
||||||
|
onMouseDown={(event) => {
|
||||||
|
if (event.button !== 1) return
|
||||||
|
closeTab(event)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<a
|
||||||
|
data-slot="tab-link"
|
||||||
|
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 overflow-hidden text-[13px] font-medium leading-5 text-v2-text-text-faint group-data-[active='true']:text-[var(--v2-text-text-base)]"
|
||||||
|
>
|
||||||
|
<span class="flex size-4 shrink-0 rotate-90 items-center justify-center">
|
||||||
|
<IconV2 name="edit" />
|
||||||
|
</span>
|
||||||
|
<span class="truncate leading-5">{props.title}</span>
|
||||||
|
</a>
|
||||||
|
<div data-slot="tab-close" class="absolute right-0 inset-y-0 flex w-7 items-center justify-center">
|
||||||
|
<IconButtonV2
|
||||||
|
size="small"
|
||||||
|
variant="ghost-muted"
|
||||||
|
onPointerDown={(event) => {
|
||||||
|
event.preventDefault()
|
||||||
|
event.stopPropagation()
|
||||||
|
}}
|
||||||
|
onMouseDown={(event) => {
|
||||||
|
event.preventDefault()
|
||||||
|
event.stopPropagation()
|
||||||
|
}}
|
||||||
|
onClick={closeTab}
|
||||||
|
icon={<IconV2 name="xmark-small" />}
|
||||||
|
aria-label="Close tab"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
581
packages/app/src/components/titlebar-tab-strip.tsx
Normal file
581
packages/app/src/components/titlebar-tab-strip.tsx
Normal file
|
|
@ -0,0 +1,581 @@
|
||||||
|
import {
|
||||||
|
createEffect,
|
||||||
|
createMemo,
|
||||||
|
createResource,
|
||||||
|
createRoot,
|
||||||
|
createSignal,
|
||||||
|
For,
|
||||||
|
onCleanup,
|
||||||
|
onMount,
|
||||||
|
Show,
|
||||||
|
} from "solid-js"
|
||||||
|
import { Portal } from "solid-js/web"
|
||||||
|
import { createStore } from "solid-js/store"
|
||||||
|
import { makeEventListener } from "@solid-primitives/event-listener"
|
||||||
|
import { createResizeObserver } from "@solid-primitives/resize-observer"
|
||||||
|
import { tabHref, tabKey, type SessionTab, type Tab } from "@/context/tabs"
|
||||||
|
import { ServerConnection } from "@/context/server"
|
||||||
|
import { DraftTabItem, TabNavItem } from "@/components/titlebar-tab-nav"
|
||||||
|
import { useGlobal, type ServerCtx } from "@/context/global"
|
||||||
|
import { useLanguage } from "@/context/language"
|
||||||
|
import { useCommand } from "@/context/command"
|
||||||
|
import { useTabs } from "@/context/tabs"
|
||||||
|
import { createTabPromptState } from "@/context/prompt"
|
||||||
|
import { base64Encode } from "@opencode-ai/core/util/encode"
|
||||||
|
import {
|
||||||
|
captureTabPointerDown,
|
||||||
|
canStartTabDrag,
|
||||||
|
createTabDragPreview,
|
||||||
|
isPrimaryPointerPressed,
|
||||||
|
isTabCloseTarget,
|
||||||
|
} from "./titlebar-tab-gesture"
|
||||||
|
import {
|
||||||
|
ACTIVATION_DISTANCE,
|
||||||
|
autoscrollSpeed,
|
||||||
|
captureTabDragLayout,
|
||||||
|
clampFloaterLeft,
|
||||||
|
draftOrderChanged,
|
||||||
|
insertIndexFromVirtualLayout,
|
||||||
|
movePlaceholder,
|
||||||
|
pointerDistance,
|
||||||
|
syncLayoutScroll,
|
||||||
|
type TabDragLayout,
|
||||||
|
} from "@/components/titlebar-tab-drag"
|
||||||
|
|
||||||
|
function SessionTabSlot(props: {
|
||||||
|
tab: SessionTab
|
||||||
|
id: string
|
||||||
|
first: () => boolean
|
||||||
|
active: () => boolean
|
||||||
|
activeServerKey: ServerConnection.Key
|
||||||
|
forceTruncate: boolean
|
||||||
|
dragActive: boolean
|
||||||
|
dragged: () => boolean
|
||||||
|
pressed: () => boolean
|
||||||
|
serverCtx: () => ServerCtx | undefined
|
||||||
|
suppressNavigation: () => boolean
|
||||||
|
onPointerDown: (event: PointerEvent) => void
|
||||||
|
onNavigate: (element: HTMLDivElement) => void
|
||||||
|
onClose: () => void
|
||||||
|
}) {
|
||||||
|
const tabs = useTabs()
|
||||||
|
let ref!: HTMLDivElement
|
||||||
|
const sdk = createMemo(() => props.serverCtx()?.sdk ?? null)
|
||||||
|
const cachedSession = createMemo(() => props.serverCtx()?.sync.session.peek(props.tab.sessionId))
|
||||||
|
const [loadedSession] = createResource(
|
||||||
|
() => {
|
||||||
|
const ctx = props.serverCtx()
|
||||||
|
return ctx ? { id: props.tab.sessionId, ctx } : null
|
||||||
|
},
|
||||||
|
({ id, ctx }) => ctx.sync.session.resolve(id).catch(() => undefined),
|
||||||
|
)
|
||||||
|
const session = createMemo(() => cachedSession() ?? loadedSession())
|
||||||
|
let prefetched = false
|
||||||
|
|
||||||
|
createEffect(() => {
|
||||||
|
const ctx = props.serverCtx()
|
||||||
|
const value = session()
|
||||||
|
if (!ctx || !value || prefetched) return
|
||||||
|
prefetched = true
|
||||||
|
createRoot((dispose) => {
|
||||||
|
try {
|
||||||
|
void ctx.sync
|
||||||
|
.ensureDirSyncContext(value.directory)
|
||||||
|
.session.sync(value.id)
|
||||||
|
.catch(() => {})
|
||||||
|
.finally(dispose)
|
||||||
|
} catch {
|
||||||
|
dispose()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
createEffect(() => {
|
||||||
|
const value = session()
|
||||||
|
const current = sdk()
|
||||||
|
if (!value || !current) return
|
||||||
|
createTabPromptState(tabs, props.tab, current.scope, {
|
||||||
|
dir: base64Encode(value.directory),
|
||||||
|
id: value.id,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-titlebar-tab-slot
|
||||||
|
data-tab-key={props.id}
|
||||||
|
class="flex shrink-0"
|
||||||
|
classList={{
|
||||||
|
hidden: !session(),
|
||||||
|
"ml-1.5 border-l border-[var(--v2-background-bg-layer-02)] pl-1.5": !props.first(),
|
||||||
|
"pointer-events-none": props.dragActive,
|
||||||
|
}}
|
||||||
|
onPointerDown={props.onPointerDown}
|
||||||
|
>
|
||||||
|
<TabNavItem
|
||||||
|
ref={ref}
|
||||||
|
href={tabHref(props.tab)}
|
||||||
|
server={props.tab.server}
|
||||||
|
session={session}
|
||||||
|
onTitleChange={(title) => {
|
||||||
|
const value = session()
|
||||||
|
const ctx = props.serverCtx()
|
||||||
|
if (value && ctx) ctx.sync.session.remember({ ...value, title })
|
||||||
|
}}
|
||||||
|
onTitleChangeFailed={(title) => {
|
||||||
|
const value = session()
|
||||||
|
const ctx = props.serverCtx()
|
||||||
|
if (value && ctx) ctx.sync.session.remember({ ...value, title })
|
||||||
|
}}
|
||||||
|
onNavigate={() => props.onNavigate(ref)}
|
||||||
|
onClose={props.onClose}
|
||||||
|
active={props.active()}
|
||||||
|
activeServer={props.tab.server === props.activeServerKey}
|
||||||
|
forceTruncate={props.forceTruncate}
|
||||||
|
suppressNavigation={props.suppressNavigation}
|
||||||
|
pressed={props.pressed()}
|
||||||
|
hidden={props.dragged()}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TitlebarTabStrip(props: {
|
||||||
|
tabs: Tab[]
|
||||||
|
currentTab: () => Tab | undefined
|
||||||
|
activeServerKey: ServerConnection.Key
|
||||||
|
forceTruncate: boolean
|
||||||
|
onNavigate: (tab: Tab, el?: HTMLDivElement) => void
|
||||||
|
onClose: (tab: Tab) => void
|
||||||
|
onReorder: (keys: string[]) => void
|
||||||
|
onOverflowChange: (overflowing: boolean) => void
|
||||||
|
}) {
|
||||||
|
const global = useGlobal()
|
||||||
|
const language = useLanguage()
|
||||||
|
const [drag, setDrag] = createStore({
|
||||||
|
active: false,
|
||||||
|
draggedId: undefined as string | undefined,
|
||||||
|
placeholderIndex: 0,
|
||||||
|
draftOrder: [] as string[],
|
||||||
|
initialOrder: [] as string[],
|
||||||
|
draggedWidth: 0,
|
||||||
|
pointerX: 0,
|
||||||
|
grabOffsetX: 0,
|
||||||
|
floaterTop: 0,
|
||||||
|
})
|
||||||
|
|
||||||
|
const [gesture, setGesture] = createStore({
|
||||||
|
pending: undefined as
|
||||||
|
| {
|
||||||
|
id: string
|
||||||
|
startX: number
|
||||||
|
startY: number
|
||||||
|
grabOffsetX: number
|
||||||
|
grabOffsetY: number
|
||||||
|
pointerId: number
|
||||||
|
width: number
|
||||||
|
element: HTMLDivElement
|
||||||
|
}
|
||||||
|
| undefined,
|
||||||
|
})
|
||||||
|
|
||||||
|
const [suppressNavigation, setSuppressNavigation] = createSignal(false)
|
||||||
|
const [pressedId, setPressedId] = createSignal<string | undefined>()
|
||||||
|
const [stripScrollLeft, setStripScrollLeft] = createSignal(0)
|
||||||
|
let scrollRef!: HTMLDivElement
|
||||||
|
let listRef!: HTMLDivElement
|
||||||
|
let dragLayout: TabDragLayout | undefined
|
||||||
|
let dragPointerId: number | undefined
|
||||||
|
let autoscrollFrame: number | undefined
|
||||||
|
let resizeFrame: number | undefined
|
||||||
|
let dragPreview: HTMLDivElement | undefined
|
||||||
|
|
||||||
|
const tabIds = () => props.tabs.map(tabKey)
|
||||||
|
|
||||||
|
const displayTabs = createMemo(() => {
|
||||||
|
if (!drag.active || drag.draftOrder.length === 0) return props.tabs
|
||||||
|
const byKey = new Map(props.tabs.map((tab) => [tabKey(tab), tab]))
|
||||||
|
return drag.draftOrder.map((key) => byKey.get(key)).filter((tab): tab is Tab => !!tab)
|
||||||
|
})
|
||||||
|
|
||||||
|
function refreshOverflow() {
|
||||||
|
if (!scrollRef) return
|
||||||
|
props.onOverflowChange(scrollRef.scrollWidth > scrollRef.clientWidth)
|
||||||
|
}
|
||||||
|
|
||||||
|
createResizeObserver(
|
||||||
|
() => [scrollRef, listRef],
|
||||||
|
() => {
|
||||||
|
if (resizeFrame !== undefined) return
|
||||||
|
resizeFrame = requestAnimationFrame(() => {
|
||||||
|
resizeFrame = undefined
|
||||||
|
refreshOverflow()
|
||||||
|
if (!drag.active || !listRef) return
|
||||||
|
dragLayout = captureTabDragLayout(listRef, drag.draftOrder)
|
||||||
|
updateInsertIndex()
|
||||||
|
})
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
function syncScroll() {
|
||||||
|
if (!scrollRef || !listRef || !dragLayout) return
|
||||||
|
syncLayoutScroll(listRef, dragLayout)
|
||||||
|
setStripScrollLeft(scrollRef.scrollLeft)
|
||||||
|
updateInsertIndex()
|
||||||
|
}
|
||||||
|
|
||||||
|
function stopAutoscroll() {
|
||||||
|
if (autoscrollFrame === undefined) return
|
||||||
|
cancelAnimationFrame(autoscrollFrame)
|
||||||
|
autoscrollFrame = undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
function tickAutoscroll() {
|
||||||
|
if (!drag.active || !scrollRef) return
|
||||||
|
|
||||||
|
const strip = scrollRef.getBoundingClientRect()
|
||||||
|
const speed = autoscrollSpeed(drag.pointerX, strip.left, strip.right)
|
||||||
|
|
||||||
|
if (speed !== 0) {
|
||||||
|
scrollRef.scrollLeft += speed
|
||||||
|
syncScroll()
|
||||||
|
}
|
||||||
|
|
||||||
|
autoscrollFrame = requestAnimationFrame(tickAutoscroll)
|
||||||
|
}
|
||||||
|
|
||||||
|
function startAutoscroll() {
|
||||||
|
stopAutoscroll()
|
||||||
|
autoscrollFrame = requestAnimationFrame(tickAutoscroll)
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyPlaceholderIndex(nextIndex: number) {
|
||||||
|
const id = drag.draggedId
|
||||||
|
if (!id) return
|
||||||
|
const next = movePlaceholder(drag.draftOrder, id, nextIndex)
|
||||||
|
setDrag({
|
||||||
|
draftOrder: next,
|
||||||
|
placeholderIndex: nextIndex,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateInsertIndex() {
|
||||||
|
if (!drag.active || !dragLayout) return
|
||||||
|
const draggedId = drag.draggedId
|
||||||
|
if (!draggedId) return
|
||||||
|
const nextIndex = insertIndexFromVirtualLayout(
|
||||||
|
drag.pointerX,
|
||||||
|
drag.draftOrder,
|
||||||
|
draggedId,
|
||||||
|
drag.placeholderIndex,
|
||||||
|
dragLayout,
|
||||||
|
)
|
||||||
|
if (nextIndex === drag.placeholderIndex) return
|
||||||
|
applyPlaceholderIndex(nextIndex)
|
||||||
|
}
|
||||||
|
|
||||||
|
function startDrag(id: string) {
|
||||||
|
const order = tabIds()
|
||||||
|
const index = order.indexOf(id)
|
||||||
|
const pending = gesture.pending
|
||||||
|
if (index === -1 || !pending || !listRef || !scrollRef) return
|
||||||
|
|
||||||
|
dragLayout = captureTabDragLayout(listRef, order)
|
||||||
|
dragPreview = createTabDragPreview(pending.element)
|
||||||
|
dragPointerId = pending.pointerId
|
||||||
|
setGesture("pending", undefined)
|
||||||
|
|
||||||
|
setDrag({
|
||||||
|
active: true,
|
||||||
|
draggedId: id,
|
||||||
|
placeholderIndex: index,
|
||||||
|
draftOrder: order,
|
||||||
|
initialOrder: order,
|
||||||
|
draggedWidth: pending.width,
|
||||||
|
pointerX: pending.startX,
|
||||||
|
grabOffsetX: pending.grabOffsetX,
|
||||||
|
floaterTop: pending.startY - pending.grabOffsetY,
|
||||||
|
})
|
||||||
|
setPressedId(undefined)
|
||||||
|
setStripScrollLeft(scrollRef.scrollLeft)
|
||||||
|
startAutoscroll()
|
||||||
|
}
|
||||||
|
|
||||||
|
function endDrag(commit: boolean) {
|
||||||
|
const initial = drag.initialOrder
|
||||||
|
const final = drag.draftOrder
|
||||||
|
const moved = drag.active
|
||||||
|
|
||||||
|
if (commit && moved && draftOrderChanged(initial, final)) {
|
||||||
|
props.onReorder(final)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (moved) setSuppressNavigation(true)
|
||||||
|
|
||||||
|
setDrag({
|
||||||
|
active: false,
|
||||||
|
draggedId: undefined,
|
||||||
|
placeholderIndex: 0,
|
||||||
|
draftOrder: [],
|
||||||
|
initialOrder: [],
|
||||||
|
draggedWidth: 0,
|
||||||
|
pointerX: 0,
|
||||||
|
grabOffsetX: 0,
|
||||||
|
floaterTop: 0,
|
||||||
|
})
|
||||||
|
|
||||||
|
dragLayout = undefined
|
||||||
|
dragPreview = undefined
|
||||||
|
dragPointerId = undefined
|
||||||
|
setGesture("pending", undefined)
|
||||||
|
setPressedId(undefined)
|
||||||
|
stopAutoscroll()
|
||||||
|
refreshOverflow()
|
||||||
|
requestAnimationFrame(() => setSuppressNavigation(false))
|
||||||
|
}
|
||||||
|
|
||||||
|
function onPointerDown(id: string, event: PointerEvent) {
|
||||||
|
if (event.button !== 0 || drag.active) return
|
||||||
|
if (!canStartTabDrag(event.pointerType)) return
|
||||||
|
if (isTabCloseTarget(event.target)) return
|
||||||
|
const tabEl = (event.currentTarget as HTMLElement).querySelector<HTMLDivElement>("[data-titlebar-tab]")
|
||||||
|
if (!tabEl) return
|
||||||
|
if (!tabEl.querySelector('[data-slot="tab-link"]')) return
|
||||||
|
const tab = props.tabs.find((item) => tabKey(item) === id)
|
||||||
|
if (!tab) return
|
||||||
|
const pointer = captureTabPointerDown(tabEl, event.clientX, event.clientY)
|
||||||
|
setSuppressNavigation(true)
|
||||||
|
props.onNavigate(tab, tabEl)
|
||||||
|
setPressedId(id)
|
||||||
|
setGesture("pending", {
|
||||||
|
id,
|
||||||
|
pointerId: event.pointerId,
|
||||||
|
...pointer,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function onPointerMove(event: PointerEvent) {
|
||||||
|
const pending = gesture.pending
|
||||||
|
if (pending && event.pointerId !== pending.pointerId) return
|
||||||
|
if (drag.active && dragPointerId !== undefined && event.pointerId !== dragPointerId) return
|
||||||
|
if (!isPrimaryPointerPressed(event.buttons)) {
|
||||||
|
if (drag.active) endDrag(true)
|
||||||
|
if (pending) {
|
||||||
|
setGesture("pending", undefined)
|
||||||
|
setPressedId(undefined)
|
||||||
|
requestAnimationFrame(() => setSuppressNavigation(false))
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pending && !drag.active) {
|
||||||
|
if (pointerDistance(pending.startX, pending.startY, event.clientX, event.clientY) < ACTIVATION_DISTANCE) return
|
||||||
|
startDrag(pending.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!drag.active) return
|
||||||
|
|
||||||
|
setDrag("pointerX", event.clientX)
|
||||||
|
syncScroll()
|
||||||
|
}
|
||||||
|
|
||||||
|
function onPointerUp(event: PointerEvent) {
|
||||||
|
if (drag.active) {
|
||||||
|
if (dragPointerId !== undefined && event.pointerId !== dragPointerId) return
|
||||||
|
setDrag("pointerX", event.clientX)
|
||||||
|
syncScroll()
|
||||||
|
endDrag(true)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const pending = gesture.pending
|
||||||
|
if (pending && event.pointerId !== pending.pointerId) return
|
||||||
|
|
||||||
|
setGesture("pending", undefined)
|
||||||
|
setPressedId(undefined)
|
||||||
|
requestAnimationFrame(() => setSuppressNavigation(false))
|
||||||
|
}
|
||||||
|
|
||||||
|
function onPointerCancel(event: PointerEvent) {
|
||||||
|
if (drag.active) {
|
||||||
|
if (dragPointerId !== undefined && event.pointerId !== dragPointerId) return
|
||||||
|
endDrag(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!gesture.pending) return
|
||||||
|
if (gesture.pending.pointerId !== event.pointerId) return
|
||||||
|
setGesture("pending", undefined)
|
||||||
|
setPressedId(undefined)
|
||||||
|
requestAnimationFrame(() => setSuppressNavigation(false))
|
||||||
|
}
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
const cleanups = [
|
||||||
|
makeEventListener(window, "pointermove", onPointerMove),
|
||||||
|
makeEventListener(window, "pointerup", onPointerUp),
|
||||||
|
makeEventListener(window, "pointercancel", onPointerCancel),
|
||||||
|
]
|
||||||
|
refreshOverflow()
|
||||||
|
onCleanup(() => cleanups.forEach((cleanup) => cleanup()))
|
||||||
|
})
|
||||||
|
|
||||||
|
onCleanup(stopAutoscroll)
|
||||||
|
onCleanup(() => {
|
||||||
|
if (resizeFrame !== undefined) cancelAnimationFrame(resizeFrame)
|
||||||
|
})
|
||||||
|
|
||||||
|
createEffect(() => {
|
||||||
|
props.tabs.length
|
||||||
|
tabIds()
|
||||||
|
refreshOverflow()
|
||||||
|
})
|
||||||
|
|
||||||
|
createEffect(() => {
|
||||||
|
if (!drag.active || !scrollRef) return
|
||||||
|
onCleanup(makeEventListener(scrollRef, "scroll", syncScroll))
|
||||||
|
})
|
||||||
|
|
||||||
|
const floaterStyle = () => {
|
||||||
|
stripScrollLeft()
|
||||||
|
const strip = scrollRef?.getBoundingClientRect()
|
||||||
|
const left = strip
|
||||||
|
? clampFloaterLeft(drag.pointerX - drag.grabOffsetX, drag.draggedWidth, strip.left, strip.right)
|
||||||
|
: drag.pointerX - drag.grabOffsetX
|
||||||
|
|
||||||
|
return {
|
||||||
|
position: "fixed" as const,
|
||||||
|
top: `${drag.floaterTop}px`,
|
||||||
|
left: `${left}px`,
|
||||||
|
width: `${drag.draggedWidth}px`,
|
||||||
|
"z-index": "10000",
|
||||||
|
"pointer-events": "none" as const,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const draggedTab = createMemo(() => {
|
||||||
|
const id = drag.draggedId
|
||||||
|
if (!id) return
|
||||||
|
return props.tabs.find((tab) => tabKey(tab) === id)
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div data-slot="titlebar-tabs" class="relative min-w-0">
|
||||||
|
<div
|
||||||
|
data-slot="titlebar-tabs-scroll"
|
||||||
|
class="flex min-w-0 flex-row items-center gap-1.5 overflow-x-auto no-scrollbar [app-region:no-drag]"
|
||||||
|
ref={scrollRef}
|
||||||
|
>
|
||||||
|
<div class="flex min-w-0 flex-row items-center" ref={listRef}>
|
||||||
|
<For each={displayTabs()}>
|
||||||
|
{(tab, index) => {
|
||||||
|
const id = tabKey(tab)
|
||||||
|
const first = () => index() === 0
|
||||||
|
let ref!: HTMLDivElement
|
||||||
|
useTabShortcut(index, () => props.onNavigate(tab, ref))
|
||||||
|
|
||||||
|
const dragged = () => drag.active && drag.draggedId === id
|
||||||
|
const serverCtx = createMemo(() => {
|
||||||
|
if (tab.type !== "session") return
|
||||||
|
const conn = global.servers.list().find((item) => ServerConnection.key(item) === tab.server)
|
||||||
|
if (conn) return global.ensureServerCtx(conn)
|
||||||
|
})
|
||||||
|
|
||||||
|
if (tab.type === "session") {
|
||||||
|
return (
|
||||||
|
<SessionTabSlot
|
||||||
|
tab={tab}
|
||||||
|
id={id}
|
||||||
|
first={first}
|
||||||
|
active={() => props.currentTab() === tab}
|
||||||
|
activeServerKey={props.activeServerKey}
|
||||||
|
forceTruncate={props.forceTruncate}
|
||||||
|
dragActive={drag.active}
|
||||||
|
dragged={dragged}
|
||||||
|
pressed={() => pressedId() === id}
|
||||||
|
serverCtx={serverCtx}
|
||||||
|
suppressNavigation={() => suppressNavigation()}
|
||||||
|
onPointerDown={(event) => {
|
||||||
|
if (dragged()) return
|
||||||
|
onPointerDown(id, event)
|
||||||
|
}}
|
||||||
|
onNavigate={(element) => props.onNavigate(tab, element)}
|
||||||
|
onClose={() => props.onClose(tab)}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-titlebar-tab-slot
|
||||||
|
data-tab-key={id}
|
||||||
|
class="flex shrink-0"
|
||||||
|
classList={{
|
||||||
|
"ml-1.5 border-l border-[var(--v2-background-bg-layer-02)] pl-1.5": !first(),
|
||||||
|
"pointer-events-none": drag.active,
|
||||||
|
}}
|
||||||
|
onPointerDown={(event) => {
|
||||||
|
if (dragged()) return
|
||||||
|
onPointerDown(id, event)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<DraftTabItem
|
||||||
|
ref={ref}
|
||||||
|
href={tabHref(tab)}
|
||||||
|
title={language.t("command.session.new")}
|
||||||
|
onNavigate={() => props.onNavigate(tab, ref)}
|
||||||
|
onClose={() => props.onClose(tab)}
|
||||||
|
suppressNavigation={() => suppressNavigation()}
|
||||||
|
active={props.currentTab() === tab}
|
||||||
|
pressed={pressedId() === id}
|
||||||
|
hidden={dragged()}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
</For>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
data-slot="titlebar-tabs-fade-left"
|
||||||
|
aria-hidden="true"
|
||||||
|
class="pointer-events-none absolute inset-y-0 left-0 z-10 w-6 bg-[linear-gradient(to_right,var(--v2-background-bg-deep),transparent)]"
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
data-slot="titlebar-tabs-fade-right"
|
||||||
|
aria-hidden="true"
|
||||||
|
class="pointer-events-none absolute inset-y-0 right-0 z-10 w-6 bg-[linear-gradient(to_left,var(--v2-background-bg-deep),transparent)]"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Show when={drag.active && draggedTab() && dragPreview}>
|
||||||
|
{(_) => (
|
||||||
|
<Portal>
|
||||||
|
<div data-titlebar-tab-preview style={floaterStyle()}>
|
||||||
|
{dragPreview}
|
||||||
|
</div>
|
||||||
|
</Portal>
|
||||||
|
)}
|
||||||
|
</Show>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function useTabShortcut(index: () => number, onSelect: () => void) {
|
||||||
|
const command = useCommand()
|
||||||
|
|
||||||
|
command.register(() => {
|
||||||
|
const number = index() + 1
|
||||||
|
if (number > 9) return []
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
id: `tab.${number}`,
|
||||||
|
category: "tab",
|
||||||
|
title: "",
|
||||||
|
keybind: `mod+${number}`,
|
||||||
|
hidden: true,
|
||||||
|
onSelect,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
@ -1,16 +1,4 @@
|
||||||
import {
|
import { createEffect, createMemo, createResource, createSignal, Match, Show, Switch, untrack } from "solid-js"
|
||||||
createEffect,
|
|
||||||
createMemo,
|
|
||||||
createResource,
|
|
||||||
createRoot,
|
|
||||||
createSignal,
|
|
||||||
For,
|
|
||||||
Match,
|
|
||||||
onMount,
|
|
||||||
Show,
|
|
||||||
Switch,
|
|
||||||
untrack,
|
|
||||||
} from "solid-js"
|
|
||||||
import { createStore } from "solid-js/store"
|
import { createStore } from "solid-js/store"
|
||||||
import { useLocation, useNavigate, useParams } from "@solidjs/router"
|
import { useLocation, useNavigate, useParams } from "@solidjs/router"
|
||||||
import { IconButton } from "@opencode-ai/ui/icon-button"
|
import { IconButton } from "@opencode-ai/ui/icon-button"
|
||||||
|
|
@ -30,19 +18,14 @@ import { useLanguage } from "@/context/language"
|
||||||
import { useSettings } from "@/context/settings"
|
import { useSettings } from "@/context/settings"
|
||||||
import { WindowsAppMenu } from "./windows-app-menu"
|
import { WindowsAppMenu } from "./windows-app-menu"
|
||||||
import { applyPath, backPath, forwardPath } from "./titlebar-history"
|
import { applyPath, backPath, forwardPath } from "./titlebar-history"
|
||||||
import { projectForSession } from "@/pages/layout/helpers"
|
import { TitlebarTabStrip } from "@/components/titlebar-tab-strip"
|
||||||
import { SessionTabAvatar } from "@/pages/layout/session-tab-avatar"
|
|
||||||
import { makeEventListener } from "@solid-primitives/event-listener"
|
import { makeEventListener } from "@solid-primitives/event-listener"
|
||||||
import { createResizeObserver } from "@solid-primitives/resize-observer"
|
|
||||||
import { createMediaQuery } from "@solid-primitives/media"
|
import { createMediaQuery } from "@solid-primitives/media"
|
||||||
import { readSessionTabsRemovedDetail, SESSION_TABS_REMOVED_EVENT } from "@/components/titlebar-session-events"
|
import { readSessionTabsRemovedDetail, SESSION_TABS_REMOVED_EVENT } from "@/components/titlebar-session-events"
|
||||||
import { useGlobal } from "@/context/global"
|
import { useGlobal } from "@/context/global"
|
||||||
import { ServerConnection, useServer } from "@/context/server"
|
import { ServerConnection, useServer } from "@/context/server"
|
||||||
import { tabHref, useTabs } from "@/context/tabs"
|
import { tabKey, useTabs } from "@/context/tabs"
|
||||||
import "./titlebar.css"
|
import "./titlebar.css"
|
||||||
import { Session } from "@opencode-ai/sdk/v2"
|
|
||||||
import { base64Encode } from "@opencode-ai/core/util/encode"
|
|
||||||
import { createTabPromptState } from "@/context/prompt"
|
|
||||||
|
|
||||||
type TauriDesktopWindow = {
|
type TauriDesktopWindow = {
|
||||||
startDragging?: () => Promise<void>
|
startDragging?: () => Promise<void>
|
||||||
|
|
@ -256,7 +239,6 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
|
||||||
<Switch>
|
<Switch>
|
||||||
<Match when={useV2Titlebar()}>
|
<Match when={useV2Titlebar()}>
|
||||||
{(_) => {
|
{(_) => {
|
||||||
const navigate = useNavigate()
|
|
||||||
const layout = useLayout()
|
const layout = useLayout()
|
||||||
const global = useGlobal()
|
const global = useGlobal()
|
||||||
|
|
||||||
|
|
@ -422,11 +404,6 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
|
||||||
})
|
})
|
||||||
|
|
||||||
const [tabsAreOverflowing, setTabsAreOverflowing] = createSignal(false)
|
const [tabsAreOverflowing, setTabsAreOverflowing] = createSignal(false)
|
||||||
let tabScrollRef!: HTMLDivElement
|
|
||||||
|
|
||||||
function refreshTabsAreOverflowing() {
|
|
||||||
setTabsAreOverflowing(tabScrollRef.scrollWidth > tabScrollRef.clientWidth)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
|
|
@ -465,161 +442,22 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
|
||||||
/>
|
/>
|
||||||
</TooltipV2>
|
</TooltipV2>
|
||||||
|
|
||||||
<div data-slot="titlebar-tabs" class="relative min-w-0">
|
<TitlebarTabStrip
|
||||||
<div
|
tabs={tabsStore}
|
||||||
data-slot="titlebar-tabs-scroll"
|
currentTab={currentTab}
|
||||||
class="flex min-w-0 flex-row items-center gap-1.5 overflow-x-auto no-scrollbar [app-region:no-drag]"
|
activeServerKey={server.key}
|
||||||
ref={(el) => {
|
forceTruncate={tabsAreOverflowing()}
|
||||||
tabScrollRef = el
|
onOverflowChange={setTabsAreOverflowing}
|
||||||
createResizeObserver(el, refreshTabsAreOverflowing)
|
onNavigate={(tab, el) => {
|
||||||
}}
|
tabs.select(tab)
|
||||||
>
|
el?.scrollIntoView({ behavior: "instant" })
|
||||||
<div
|
}}
|
||||||
class="flex min-w-0 flex-row items-center gap-1.5"
|
onClose={(tab) => {
|
||||||
ref={(el) => createResizeObserver(el, refreshTabsAreOverflowing)}
|
const index = tabsStore.findIndex((item) => tabKey(item) === tabKey(tab))
|
||||||
>
|
if (index !== -1) tabsStoreActions.removeTab(index)
|
||||||
<For each={tabsStore}>
|
}}
|
||||||
{(tab, i) => {
|
onReorder={(keys) => tabsStoreActions.reorder(keys)}
|
||||||
let ref!: HTMLDivElement
|
/>
|
||||||
useTabShortcut(i, () => tabs.select(tab))
|
|
||||||
|
|
||||||
const divider = () =>
|
|
||||||
i() !== 0 && (
|
|
||||||
<div class="w-[1.5px] h-3 shrink-0 rounded-full bg-[var(--v2-background-bg-layer-02)]" />
|
|
||||||
)
|
|
||||||
|
|
||||||
if (tab.type === "draft") {
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
{divider()}
|
|
||||||
<DraftTabItem
|
|
||||||
ref={ref}
|
|
||||||
href={tabHref(tab)}
|
|
||||||
title={language.t("command.session.new")}
|
|
||||||
active={currentTab() === tab}
|
|
||||||
onNavigate={() => {
|
|
||||||
tabs.select(tab)
|
|
||||||
ref.scrollIntoView({ behavior: "instant" })
|
|
||||||
}}
|
|
||||||
onClose={() => tabsStoreActions.removeTab(i())}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const serverCtx = createMemo(() => {
|
|
||||||
const conn = server.list.find((item) => ServerConnection.key(item) === tab.server)
|
|
||||||
return conn ? global.ensureServerCtx(conn) : undefined
|
|
||||||
})
|
|
||||||
const sdk = createMemo(() => serverCtx()?.sdk ?? null)
|
|
||||||
const cachedSession = createMemo(() => serverCtx()?.sync.session.peek(tab.sessionId))
|
|
||||||
|
|
||||||
const [loadedSession] = createResource(
|
|
||||||
() => {
|
|
||||||
const id = tab.sessionId
|
|
||||||
const ctx = serverCtx()
|
|
||||||
return ctx ? { id, ctx } : null
|
|
||||||
},
|
|
||||||
({ id, ctx }) => ctx.sync.session.resolve(id).catch(() => undefined),
|
|
||||||
)
|
|
||||||
const session = createMemo(() => cachedSession() ?? loadedSession())
|
|
||||||
let prefetched = false
|
|
||||||
|
|
||||||
createEffect(() => {
|
|
||||||
const ctx = serverCtx()
|
|
||||||
const sess = session()
|
|
||||||
if (!ctx || !sess || prefetched) return
|
|
||||||
prefetched = true
|
|
||||||
createRoot((dispose) => {
|
|
||||||
try {
|
|
||||||
void ctx.sync
|
|
||||||
.ensureDirSyncContext(sess.directory)
|
|
||||||
.session.sync(sess.id)
|
|
||||||
.catch(() => {})
|
|
||||||
.finally(dispose)
|
|
||||||
} catch {
|
|
||||||
dispose()
|
|
||||||
}
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
createEffect(() => {
|
|
||||||
if (tab.type !== "session") return
|
|
||||||
const _sdk = sdk()
|
|
||||||
if (!_sdk) return
|
|
||||||
const sess = session()
|
|
||||||
if (!sess) return
|
|
||||||
createTabPromptState(tabs, tab, _sdk.scope, {
|
|
||||||
dir: base64Encode(sess.directory),
|
|
||||||
id: sess.id,
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
{divider()}
|
|
||||||
<Show when={session()}>
|
|
||||||
{(session) => (
|
|
||||||
<TabNavItem
|
|
||||||
ref={ref}
|
|
||||||
href={tabHref(tab)}
|
|
||||||
server={tab.server}
|
|
||||||
sessionId={tab.sessionId}
|
|
||||||
session={session()}
|
|
||||||
onNavigate={() => {
|
|
||||||
tabs.select(tab)
|
|
||||||
|
|
||||||
ref.scrollIntoView({ behavior: "instant" })
|
|
||||||
}}
|
|
||||||
onClose={() => tabsStoreActions.removeTab(i())}
|
|
||||||
active={currentTab() === tab}
|
|
||||||
activeServer={tab.server === server.key}
|
|
||||||
forceTruncate={tabsAreOverflowing()}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</Show>
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
}}
|
|
||||||
</For>
|
|
||||||
<Show when={creating() && params.dir}>
|
|
||||||
{(_) => {
|
|
||||||
let ref!: HTMLDivElement
|
|
||||||
|
|
||||||
onMount(() => {
|
|
||||||
ref.scrollIntoView({ behavior: "instant" })
|
|
||||||
})
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<div class="w-[1.5px] h-3 shrink-0 rounded-full bg-[var(--v2-background-bg-layer-02)]" />
|
|
||||||
<NewSessionTabItem
|
|
||||||
ref={ref}
|
|
||||||
href={`/${params.dir}/session`}
|
|
||||||
title={language.t("command.session.new")}
|
|
||||||
onClose={() => {
|
|
||||||
const tab = tabsStore.at(-1)
|
|
||||||
if (tab) tabs.select(tab)
|
|
||||||
else navigate("/")
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
}}
|
|
||||||
</Show>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
data-slot="titlebar-tabs-fade-left"
|
|
||||||
aria-hidden="true"
|
|
||||||
class="pointer-events-none absolute inset-y-0 left-0 z-10 w-6 bg-[linear-gradient(to_right,var(--v2-background-bg-deep),transparent)]"
|
|
||||||
/>
|
|
||||||
<div
|
|
||||||
data-slot="titlebar-tabs-fade-right"
|
|
||||||
aria-hidden="true"
|
|
||||||
class="pointer-events-none absolute inset-y-0 right-0 z-10 w-6 bg-[linear-gradient(to_left,var(--v2-background-bg-deep),transparent)]"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<Show when={!(creating() && params.dir)}>
|
<Show when={!(creating() && params.dir)}>
|
||||||
<IconButtonV2
|
<IconButtonV2
|
||||||
type="button"
|
type="button"
|
||||||
|
|
@ -852,206 +690,6 @@ function TitlebarUpdateIconButton(props: { state: TitlebarUpdatePillState }) {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function useTabShortcut(index: () => number, onSelect: () => void) {
|
|
||||||
const command = useCommand()
|
|
||||||
|
|
||||||
command.register(() => {
|
|
||||||
const number = index() + 1
|
|
||||||
if (number > 9) return []
|
|
||||||
|
|
||||||
return [
|
|
||||||
{
|
|
||||||
id: `tab.${number}`,
|
|
||||||
category: "tab",
|
|
||||||
title: "",
|
|
||||||
keybind: `mod+${number}`,
|
|
||||||
hidden: true,
|
|
||||||
onSelect,
|
|
||||||
},
|
|
||||||
]
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
function TabNavItem(props: {
|
|
||||||
ref?: HTMLDivElement
|
|
||||||
href: string
|
|
||||||
server: ServerConnection.Key
|
|
||||||
sessionId?: string
|
|
||||||
hideClose?: boolean
|
|
||||||
onClose: () => void
|
|
||||||
onNavigate: () => void
|
|
||||||
active?: boolean
|
|
||||||
activeServer: boolean
|
|
||||||
forceTruncate?: boolean
|
|
||||||
session: Session
|
|
||||||
}) {
|
|
||||||
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.ensureServerCtx(conn)
|
|
||||||
})
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
ref={props.ref}
|
|
||||||
class="group relative flex h-7 w-56 shrink-0 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-active={props.active}
|
|
||||||
onMouseDown={(event) => {
|
|
||||||
if (event.button !== 1) return
|
|
||||||
closeTab(event)
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Show when={props.session}>
|
|
||||||
{(session) => {
|
|
||||||
const project = createMemo(() => projectForSession(session(), serverCtx()?.projects.list() ?? []))
|
|
||||||
|
|
||||||
return (
|
|
||||||
<a
|
|
||||||
href={props.href}
|
|
||||||
onClick={(event) => {
|
|
||||||
event.preventDefault()
|
|
||||||
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"
|
|
||||||
>
|
|
||||||
<span data-slot="project-avatar-slot">
|
|
||||||
<SessionTabAvatar
|
|
||||||
project={project()}
|
|
||||||
directory={session().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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function DraftTabItem(props: {
|
|
||||||
ref?: HTMLDivElement
|
|
||||||
href: string
|
|
||||||
title: string
|
|
||||||
active?: boolean
|
|
||||||
onNavigate: () => void
|
|
||||||
onClose: () => void
|
|
||||||
}) {
|
|
||||||
const closeTab = (event: MouseEvent) => {
|
|
||||||
event.preventDefault()
|
|
||||||
event.stopPropagation()
|
|
||||||
props.onClose()
|
|
||||||
}
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
ref={props.ref}
|
|
||||||
data-active={props.active}
|
|
||||||
class="group relative flex h-7 w-56 shrink-0 flex-row items-center gap-1.5 overflow-hidden rounded-[6px] bg-[var(--tab-bg)] pl-1.5 pr-8 whitespace-nowrap [--tab-bg:var(--v2-background-bg-deep)] hover:[--tab-bg:var(--v2-background-bg-layer-02)] data-[active='true']:[--tab-bg:var(--v2-overlay-simple-overlay-pressed)] focus-within:outline focus-within:outline-2 focus-within:outline-offset-2 focus-within:outline-[var(--v2-border-border-focus)]"
|
|
||||||
onMouseDown={(event) => {
|
|
||||||
if (event.button !== 1) return
|
|
||||||
closeTab(event)
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<a
|
|
||||||
href={props.href}
|
|
||||||
onClick={(event) => {
|
|
||||||
event.preventDefault()
|
|
||||||
props.onNavigate()
|
|
||||||
}}
|
|
||||||
class="flex h-full min-w-0 flex-1 flex-row items-center gap-1.5 overflow-hidden text-[13px] font-medium leading-5 text-v2-text-text-faint group-data-[active='true']:text-[var(--v2-text-text-base)]"
|
|
||||||
>
|
|
||||||
<span class="flex size-4 shrink-0 rotate-90 items-center justify-center">
|
|
||||||
<IconV2 name="edit" />
|
|
||||||
</span>
|
|
||||||
<span class="truncate leading-5">{props.title}</span>
|
|
||||||
</a>
|
|
||||||
<div class="absolute right-0 inset-y-0 flex w-7 items-center justify-center">
|
|
||||||
<IconButtonV2
|
|
||||||
size="small"
|
|
||||||
variant="ghost-muted"
|
|
||||||
onMouseDown={(event) => {
|
|
||||||
event.preventDefault()
|
|
||||||
event.stopPropagation()
|
|
||||||
}}
|
|
||||||
onClick={closeTab}
|
|
||||||
icon={<IconV2 name="xmark-small" />}
|
|
||||||
aria-label="Close tab"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function NewSessionTabItem(props: { ref?: HTMLDivElement; href: string; title: string; onClose: () => void }) {
|
|
||||||
const closeTab = (event: MouseEvent) => {
|
|
||||||
event.preventDefault()
|
|
||||||
event.stopPropagation()
|
|
||||||
props.onClose()
|
|
||||||
}
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
ref={props.ref}
|
|
||||||
class="group relative flex h-7 w-56 shrink-0 flex-row items-center gap-1.5 overflow-hidden rounded-[6px] bg-[var(--v2-overlay-simple-overlay-pressed)] pl-1.5 pr-8 whitespace-nowrap focus-within:outline focus-within:outline-2 focus-within:outline-offset-2 focus-within:outline-[var(--v2-border-border-focus)]"
|
|
||||||
onMouseDown={(event) => {
|
|
||||||
if (event.button !== 1) return
|
|
||||||
closeTab(event)
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<a
|
|
||||||
href={props.href}
|
|
||||||
aria-current="page"
|
|
||||||
class="flex h-full min-w-0 flex-1 flex-row items-center gap-1.5 overflow-hidden text-[13px] font-medium leading-5 text-[var(--v2-text-text-base)]"
|
|
||||||
>
|
|
||||||
<span class="flex size-4 shrink-0 rotate-90 items-center justify-center">
|
|
||||||
<IconV2 name="edit" />
|
|
||||||
</span>
|
|
||||||
<span class="truncate leading-5">{props.title}</span>
|
|
||||||
</a>
|
|
||||||
<div class="absolute right-0 inset-y-0 flex w-7 items-center justify-center">
|
|
||||||
<IconButtonV2
|
|
||||||
size="small"
|
|
||||||
variant="ghost-muted"
|
|
||||||
onMouseDown={(event) => {
|
|
||||||
event.preventDefault()
|
|
||||||
event.stopPropagation()
|
|
||||||
}}
|
|
||||||
onClick={closeTab}
|
|
||||||
icon={<IconV2 name="xmark-small" />}
|
|
||||||
aria-label="Close tab"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function ChannelIndicator() {
|
function ChannelIndicator() {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
|
|
||||||
|
|
@ -125,6 +125,16 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({
|
||||||
)
|
)
|
||||||
return next
|
return next
|
||||||
},
|
},
|
||||||
|
reorder(keys: string[]) {
|
||||||
|
setStore(
|
||||||
|
produce((tabs) => {
|
||||||
|
const byKey = new Map(tabs.map((tab) => [tabKey(tab), tab]))
|
||||||
|
const next = keys.map((key) => byKey.get(key)).filter((tab): tab is Tab => !!tab)
|
||||||
|
if (next.length !== tabs.length) return
|
||||||
|
tabs.splice(0, tabs.length, ...next)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
},
|
||||||
draft(draftID: string) {
|
draft(draftID: string) {
|
||||||
const tab = store.find((item) => item.type === "draft" && item.draftID === draftID)
|
const tab = store.find((item) => item.type === "draft" && item.draftID === draftID)
|
||||||
if (!tab || tab.type !== "draft") throw new Error(`Draft not found: ${draftID}`)
|
if (!tab || tab.type !== "draft") throw new Error(`Draft not found: ${draftID}`)
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,38 @@
|
||||||
import { describe, expect, test } from "bun:test"
|
import { describe, expect, test } from "bun:test"
|
||||||
import { ServerConnection } from "@/context/server"
|
import { ServerConnection } from "@/context/server"
|
||||||
import { legacySessionHref, requireServerKey, rootSession, selectSessionLineage, sessionHref } from "./session-route"
|
import {
|
||||||
|
legacySessionHref,
|
||||||
|
legacySessionServer,
|
||||||
|
requireServerKey,
|
||||||
|
rootSession,
|
||||||
|
selectSessionLineage,
|
||||||
|
sessionHref,
|
||||||
|
} from "./session-route"
|
||||||
|
|
||||||
describe("session routes", () => {
|
describe("session routes", () => {
|
||||||
|
test("uses the unique persisted server for a legacy session route", () => {
|
||||||
|
expect(
|
||||||
|
legacySessionServer(
|
||||||
|
[{ type: "session", server: ServerConnection.Key.make("server-b"), sessionId: "session-1" }],
|
||||||
|
"session-1",
|
||||||
|
ServerConnection.Key.make("server-a"),
|
||||||
|
),
|
||||||
|
).toBe(ServerConnection.Key.make("server-b"))
|
||||||
|
})
|
||||||
|
|
||||||
|
test("prefers the active server when a legacy session ID is ambiguous", () => {
|
||||||
|
expect(
|
||||||
|
legacySessionServer(
|
||||||
|
[
|
||||||
|
{ type: "session", server: ServerConnection.Key.make("server-a"), sessionId: "session-1" },
|
||||||
|
{ type: "session", server: ServerConnection.Key.make("server-b"), sessionId: "session-1" },
|
||||||
|
],
|
||||||
|
"session-1",
|
||||||
|
ServerConnection.Key.make("server-b"),
|
||||||
|
),
|
||||||
|
).toBe(ServerConnection.Key.make("server-b"))
|
||||||
|
})
|
||||||
|
|
||||||
test("builds and decodes a server-keyed session route", () => {
|
test("builds and decodes a server-keyed session route", () => {
|
||||||
const server = ServerConnection.Key.make("https://example.com:4096")
|
const server = ServerConnection.Key.make("https://example.com:4096")
|
||||||
const href = sessionHref(server, "session-1")
|
const href = sessionHref(server, "session-1")
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,15 @@ export function requireServerKey(segment: string | undefined) {
|
||||||
return ServerConnection.Key.make(key)
|
return ServerConnection.Key.make(key)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function legacySessionServer(
|
||||||
|
tabs: readonly { type: "session"; server: ServerConnection.Key; sessionId: string }[],
|
||||||
|
sessionID: string,
|
||||||
|
active: ServerConnection.Key,
|
||||||
|
) {
|
||||||
|
const matches = tabs.filter((tab) => tab.sessionId === sessionID)
|
||||||
|
return matches.find((tab) => tab.server === active)?.server ?? (matches.length === 1 ? matches[0]?.server : active)
|
||||||
|
}
|
||||||
|
|
||||||
type SessionParent = { id: string; parentID?: string }
|
type SessionParent = { id: string; parentID?: string }
|
||||||
|
|
||||||
export function selectSessionLineage<T extends { session: { id: string } }>(
|
export function selectSessionLineage<T extends { session: { id: string } }>(
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue