Răsfoiți Sursa

feat(app): navigate tabs on mousedown in new layout (#35042)

Luke Parker 1 lună în urmă
părinte
comite
c07ac0db2f

+ 108 - 0
packages/app/e2e/regression/tab-navigate-mousedown.spec.ts

@@ -0,0 +1,108 @@
+import { expect, test, type Page, type Route } from "@playwright/test"
+import { base64Encode } from "@opencode-ai/core/util/encode"
+
+const server = "http://127.0.0.1:4096"
+const sessionA = session("ses_tab_a", "Tab A session")
+const sessionB = session("ses_tab_b", "Tab B session")
+
+test("pressing mouse down on a tab navigates before mouse up", async ({ page }) => {
+  await mockServer(page)
+  await page.addInitScript(
+    ({ server, sessionA, sessionB }) => {
+      localStorage.setItem("settings.v3", JSON.stringify({ general: { newLayoutDesigns: true } }))
+      localStorage.setItem(
+        "opencode.window.browser.dat:tabs",
+        JSON.stringify([
+          { type: "session", server, sessionId: sessionA },
+          { type: "session", server, sessionId: sessionB },
+        ]),
+      )
+    },
+    { server, sessionA: sessionA.id, sessionB: sessionB.id },
+  )
+
+  const hrefA = `/server/${base64Encode(server)}/session/${sessionA.id}`
+  const hrefB = `/server/${base64Encode(server)}/session/${sessionB.id}`
+  await page.goto(hrefA)
+  await expect(page.getByText(sessionA.title).first()).toBeVisible()
+
+  const linkB = page.locator(`a[data-titlebar-tab-link][href="${hrefB}"]`)
+  await expect(linkB).toBeVisible()
+  const box = await linkB.boundingBox()
+  if (!box) throw new Error("tab link has no bounding box")
+  await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2)
+  await page.mouse.down()
+
+  // Navigation must happen on mousedown, before the button is released.
+  await expect(page).toHaveURL(new RegExp(`${hrefB.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}$`))
+  await page.mouse.up()
+  await expect(page).toHaveURL(new RegExp(`${hrefB.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}$`))
+})
+
+function session(id: string, title: string) {
+  return {
+    id,
+    slug: id,
+    projectID: "project-tabs",
+    directory: "C:/tab-project",
+    title,
+    version: "dev",
+    time: { created: 1, updated: 1 },
+  }
+}
+
+async function mockServer(page: Page) {
+  const sessions = [sessionA, sessionB]
+  await page.route("**/*", async (route) => {
+    const url = new URL(route.request().url())
+    if (url.origin !== server) return route.fallback()
+    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, sessions)
+    const byId = sessions.find((item) => url.pathname === `/session/${item.id}`)
+    if (byId) return json(route, byId)
+    if (/^\/session\/[^/]+$/.test(url.pathname)) return json(route, { name: "NotFoundError" }, 404)
+    if (/^\/session\/[^/]+\/message$/.test(url.pathname)) 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: sessionA.projectID,
+        worktree: sessionA.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: sessionA.directory,
+        config: sessionA.directory,
+        worktree: sessionA.directory,
+        directory: sessionA.directory,
+        home: sessionA.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" })
+}

+ 17 - 0
packages/app/src/components/titlebar-tab-nav.tsx

@@ -229,8 +229,17 @@ export function TabNavItem(props: {
             event.preventDefault()
             event.stopPropagation()
           }}
+          onMouseDown={(event) => {
+            // Navigate on mousedown to shave the press-release delay off tab switches.
+            if (event.button !== 0) return
+            if (editing()) return
+            if (props.suppressNavigation?.()) return
+            props.onNavigate()
+          }}
           onClick={(event) => {
             event.preventDefault()
+            // Mouse navigation already happened on mousedown; detail 0 means keyboard activation.
+            if (event.detail > 0) return
             if (editing()) return
             if (props.suppressNavigation?.()) return
             props.onNavigate()
@@ -368,8 +377,16 @@ export function DraftTabItem(props: {
           event.preventDefault()
           event.stopPropagation()
         }}
+        onMouseDown={(event) => {
+          // Navigate on mousedown to shave the press-release delay off tab switches.
+          if (event.button !== 0) return
+          if (props.suppressNavigation?.()) return
+          props.onNavigate()
+        }}
         onClick={(event) => {
           event.preventDefault()
+          // Mouse navigation already happened on mousedown; detail 0 means keyboard activation.
+          if (event.detail > 0) return
           if (props.suppressNavigation?.()) return
           props.onNavigate()
         }}