titlebar-tab-nav.tsx 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430
  1. import { createEffect, createMemo, createSignal, onCleanup, Show, type Ref } from "solid-js"
  2. import { makeEventListener } from "@solid-primitives/event-listener"
  3. import { createResizeObserver } from "@solid-primitives/resize-observer"
  4. import { IconButtonV2 } from "@kirincode-ai/ui/v2/icon-button-v2"
  5. import { Icon as IconV2 } from "@kirincode-ai/ui/v2/icon"
  6. import { useGlobal } from "@/context/global"
  7. import { useLanguage } from "@/context/language"
  8. import { ServerConnection, serverName } from "@/context/server"
  9. import { displayName, projectForSession } from "@/pages/layout/helpers"
  10. import { SessionTabAvatar } from "@/pages/layout/session-tab-avatar"
  11. import { showToast } from "@/utils/toast"
  12. import type { Session } from "@kirincode-ai/sdk/v2"
  13. import { canOpenTabRename, forwardTabRef } from "./titlebar-tab-gesture"
  14. import { TabPreviewPopover } from "./titlebar-tab-popover"
  15. import "./titlebar-tab-nav.css"
  16. // MouseEvent.button uses 1 for the middle/wheel button.
  17. const MIDDLE_MOUSE_BUTTON = 1
  18. export function TabNavItem(props: {
  19. ref?: Ref<HTMLDivElement>
  20. href: string
  21. server: ServerConnection.Key
  22. session: () => Session | undefined
  23. fallbackTitle?: string
  24. onTitleChange?: (title: string) => void
  25. onTitleChangeFailed?: (title: string) => void
  26. onClose: () => void
  27. onNavigate: () => void
  28. active?: boolean
  29. forceTruncate?: boolean
  30. suppressNavigation?: () => boolean
  31. dragging?: boolean
  32. pressed?: boolean
  33. hidden?: boolean
  34. }) {
  35. const language = useLanguage()
  36. const [editing, setEditing] = createSignal(false)
  37. const [titleOverflowing, setTitleOverflowing] = createSignal(false)
  38. let tabRoot!: HTMLDivElement
  39. let titleEl!: HTMLSpanElement
  40. let committing = false
  41. let measureFrame: number | undefined
  42. const closeTab = (event: MouseEvent) => {
  43. event.preventDefault()
  44. event.stopPropagation()
  45. props.onClose()
  46. }
  47. const global = useGlobal()
  48. const serverCtx = createMemo(() => {
  49. const conn = global.servers.list().find((item) => ServerConnection.key(item) === props.server)
  50. if (conn) return global.ensureServerCtx(conn)
  51. })
  52. const project = createMemo(() => {
  53. const session = props.session()
  54. if (!session) return
  55. return projectForSession(session, serverCtx()?.projects.list() ?? [])
  56. })
  57. const title = createMemo(() => props.session()?.title ?? props.fallbackTitle)
  58. const projectName = createMemo(() => {
  59. const session = props.session()
  60. if (!session) return
  61. return displayName(project() ?? { worktree: session.directory })
  62. })
  63. const previewPath = createMemo(() => {
  64. const session = props.session()
  65. if (!session) return
  66. const home = serverCtx()?.sync.data.path.home
  67. return home ? session.directory.replace(home, "~") : session.directory
  68. })
  69. // Only label the server when multiple servers are connected.
  70. const serverLabel = createMemo(() => {
  71. if (global.servers.list().length <= 1) return
  72. const conn = global.servers.list().find((item) => ServerConnection.key(item) === props.server)
  73. return conn ? serverName(conn) : undefined
  74. })
  75. const [popoverOpen, setPopoverOpen] = createSignal(false)
  76. const previewBlocked = () => !!props.dragging || editing() || !!props.pressed || !props.session()
  77. const measureTitleOverflow = () => {
  78. if (!titleEl || editing()) {
  79. setTitleOverflowing(false)
  80. return
  81. }
  82. setTitleOverflowing(titleEl.scrollWidth > titleEl.clientWidth)
  83. }
  84. const scheduleTitleOverflow = () => {
  85. if (measureFrame !== undefined) return
  86. measureFrame = requestAnimationFrame(() => {
  87. measureFrame = undefined
  88. measureTitleOverflow()
  89. })
  90. }
  91. createEffect(() => {
  92. title()
  93. props.forceTruncate
  94. editing()
  95. scheduleTitleOverflow()
  96. })
  97. createResizeObserver(() => tabRoot, scheduleTitleOverflow)
  98. onCleanup(() => {
  99. if (measureFrame !== undefined) cancelAnimationFrame(measureFrame)
  100. })
  101. const selectTitle = () => {
  102. const range = document.createRange()
  103. range.selectNodeContents(titleEl)
  104. const selection = window.getSelection()
  105. selection?.removeAllRanges()
  106. selection?.addRange(range)
  107. }
  108. const rename = async (title: string) => {
  109. const ctx = serverCtx()
  110. const session = props.session()
  111. if (!ctx || !session) return
  112. const client = ctx.sdk.createClient({ directory: session.directory, throwOnError: true })
  113. await client.session.update({ sessionID: session.id, title })
  114. }
  115. const closeRename = async (save: boolean) => {
  116. if (committing || !editing()) return
  117. committing = true
  118. const original = props.session()?.title ?? ""
  119. const next = (titleEl.textContent ?? "").trim()
  120. titleEl.scrollLeft = 0
  121. if (save && next && next !== original) props.onTitleChange?.(next)
  122. setEditing(false)
  123. if (!save || !next || next === original) {
  124. committing = false
  125. return
  126. }
  127. try {
  128. await rename(next)
  129. } catch (err) {
  130. props.onTitleChangeFailed?.(original)
  131. showToast({
  132. title: language.t("common.requestFailed"),
  133. description: err instanceof Error ? err.message : undefined,
  134. })
  135. }
  136. committing = false
  137. }
  138. createEffect(() => {
  139. if (editing()) return
  140. if (!titleEl) return
  141. const value = title()
  142. if (value === undefined) return
  143. titleEl.textContent = value
  144. })
  145. const openRename = (event: MouseEvent) => {
  146. event.preventDefault()
  147. event.stopPropagation()
  148. if (!canOpenTabRename(props.dragging, editing(), committing)) return
  149. const session = props.session()
  150. if (!session) return
  151. titleEl.textContent = session.title
  152. setEditing(true)
  153. requestAnimationFrame(() => {
  154. titleEl.focus()
  155. selectTitle()
  156. })
  157. }
  158. createEffect(() => {
  159. if (!editing()) return
  160. const cleanup = makeEventListener(
  161. document,
  162. "pointerdown",
  163. (event) => {
  164. const target = event.target
  165. if (!(target instanceof Node)) return
  166. if (tabRoot.contains(target)) return
  167. void closeRename(true)
  168. },
  169. { capture: true },
  170. )
  171. onCleanup(cleanup)
  172. })
  173. const tab = (
  174. <div
  175. ref={(el) => {
  176. tabRoot = el
  177. forwardTabRef(props.ref, el)
  178. }}
  179. data-titlebar-tab
  180. data-slot="titlebar-tab-item"
  181. data-title-overflow={titleOverflowing()}
  182. data-editing={editing()}
  183. class="group relative flex h-7 w-full min-w-0 select-none flex-row items-center gap-1.5 overflow-hidden whitespace-nowrap rounded-[6px] bg-[var(--tab-bg)] px-1.5 [container-type:inline-size] [--tab-bg:var(--v2-background-bg-deep)] hover:[--tab-bg:var(--v2-background-bg-layer-02)] has-[>a:focus-visible]:[--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)]"
  184. classList={{ invisible: props.hidden }}
  185. data-active={props.active}
  186. data-dragging={props.dragging}
  187. data-pressed={props.pressed}
  188. onMouseDown={(event) => {
  189. if (event.button !== MIDDLE_MOUSE_BUTTON) return
  190. event.preventDefault()
  191. event.stopPropagation()
  192. }}
  193. onAuxClick={(event) => {
  194. if (event.button !== MIDDLE_MOUSE_BUTTON) return
  195. closeTab(event)
  196. }}
  197. >
  198. <Show when={title() !== undefined}>
  199. <a
  200. data-slot="tab-link"
  201. data-titlebar-tab-link
  202. href={props.href}
  203. draggable={false}
  204. onDragStart={(event) => {
  205. event.preventDefault()
  206. event.stopPropagation()
  207. }}
  208. onMouseDown={(event) => {
  209. // Navigate on mousedown to shave the press-release delay off tab switches.
  210. if (event.button !== 0) return
  211. if (editing()) return
  212. if (props.suppressNavigation?.()) return
  213. props.onNavigate()
  214. }}
  215. onClick={(event) => {
  216. event.preventDefault()
  217. // Mouse navigation already happened on mousedown; detail 0 means keyboard activation.
  218. if (event.detail > 0) return
  219. if (editing()) return
  220. if (props.suppressNavigation?.()) return
  221. props.onNavigate()
  222. }}
  223. 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]"
  224. >
  225. <span data-slot="project-avatar-slot" class="flex size-4 shrink-0 items-center justify-center">
  226. <Show
  227. when={props.session()}
  228. fallback={
  229. <span class="block size-4 rounded-[3px] border border-v2-border-border-muted" aria-hidden="true" />
  230. }
  231. >
  232. {(session) => (
  233. <SessionTabAvatar
  234. project={project()}
  235. directory={session().directory}
  236. sessionId={session().id}
  237. server={props.server}
  238. />
  239. )}
  240. </Show>
  241. </span>
  242. <span
  243. ref={(el) => {
  244. titleEl = el
  245. titleEl.textContent = title() ?? ""
  246. }}
  247. data-slot="tab-title"
  248. data-titlebar-tab-title
  249. class="min-w-0 flex-1 outline-none leading-4"
  250. classList={{
  251. "overflow-hidden text-clip whitespace-nowrap": !editing(),
  252. "select-text": editing(),
  253. }}
  254. contenteditable={editing() ? true : undefined}
  255. onDblClick={openRename}
  256. onKeyDown={(event) => {
  257. event.stopPropagation()
  258. if (event.key === "Enter") {
  259. event.preventDefault()
  260. void closeRename(true)
  261. return
  262. }
  263. if (event.key !== "Escape") return
  264. event.preventDefault()
  265. titleEl.textContent = props.session()?.title ?? ""
  266. void closeRename(false)
  267. }}
  268. onBlur={() => void closeRename(true)}
  269. onPointerDown={(event) => {
  270. if (!editing()) return
  271. event.stopPropagation()
  272. }}
  273. onClick={(event) => {
  274. if (!editing()) return
  275. event.preventDefault()
  276. }}
  277. />
  278. </a>
  279. </Show>
  280. <div data-slot="tab-close" class="group-hover:bg-[var(--tab-bg)] group-data-[active=true]:bg-[var(--tab-bg)]">
  281. <IconButtonV2
  282. size="small"
  283. variant="ghost-muted"
  284. class="hover-reveal relative z-10 group-hover:opacity-100 group-data-[active=true]:opacity-100 group-data-[editing=true]:opacity-100"
  285. onPointerDown={(event) => {
  286. event.preventDefault()
  287. event.stopPropagation()
  288. }}
  289. onClick={closeTab}
  290. icon={<IconV2 name="xmark-small" />}
  291. />
  292. </div>
  293. </div>
  294. )
  295. return (
  296. <TabPreviewPopover
  297. trigger={tab}
  298. open={popoverOpen() && !previewBlocked()}
  299. onOpenChange={(value) => {
  300. if (value && previewBlocked()) return
  301. setPopoverOpen(value)
  302. }}
  303. data={{
  304. projectName: projectName(),
  305. title: props.session()?.title,
  306. path: previewPath(),
  307. serverName: serverLabel(),
  308. }}
  309. />
  310. )
  311. }
  312. export function DraftTabItem(props: {
  313. ref?: Ref<HTMLDivElement>
  314. href: string
  315. title: string
  316. active?: boolean
  317. onNavigate: () => void
  318. onClose: () => void
  319. suppressNavigation?: () => boolean
  320. dragging?: boolean
  321. pressed?: boolean
  322. hidden?: boolean
  323. }) {
  324. const closeTab = (event: MouseEvent) => {
  325. event.preventDefault()
  326. event.stopPropagation()
  327. props.onClose()
  328. }
  329. return (
  330. <div
  331. ref={(el) => forwardTabRef(props.ref, el)}
  332. data-titlebar-tab
  333. data-slot="titlebar-tab-item"
  334. data-active={props.active}
  335. data-dragging={props.dragging}
  336. data-pressed={props.pressed}
  337. class="group relative flex h-7 w-full min-w-0 flex-row items-center gap-1.5 overflow-hidden rounded-[6px] bg-[var(--tab-bg)] px-1.5 [container-type:inline-size] whitespace-nowrap [--tab-bg:var(--v2-background-bg-deep)] hover:[--tab-bg:var(--v2-background-bg-layer-02)] has-[>a:focus-visible]:[--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)]"
  338. classList={{ invisible: props.hidden }}
  339. onMouseDown={(event) => {
  340. if (event.button !== MIDDLE_MOUSE_BUTTON) return
  341. event.preventDefault()
  342. event.stopPropagation()
  343. }}
  344. onAuxClick={(event) => {
  345. if (event.button !== MIDDLE_MOUSE_BUTTON) return
  346. closeTab(event)
  347. }}
  348. >
  349. <a
  350. data-slot="tab-link"
  351. data-titlebar-tab-link
  352. href={props.href}
  353. draggable={false}
  354. onDragStart={(event) => {
  355. event.preventDefault()
  356. event.stopPropagation()
  357. }}
  358. onMouseDown={(event) => {
  359. // Navigate on mousedown to shave the press-release delay off tab switches.
  360. if (event.button !== 0) return
  361. if (props.suppressNavigation?.()) return
  362. props.onNavigate()
  363. }}
  364. onClick={(event) => {
  365. event.preventDefault()
  366. // Mouse navigation already happened on mousedown; detail 0 means keyboard activation.
  367. if (event.detail > 0) return
  368. if (props.suppressNavigation?.()) return
  369. props.onNavigate()
  370. }}
  371. 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]"
  372. >
  373. <span class="flex size-4 shrink-0 items-center justify-center">
  374. <IconV2 name="edit" />
  375. </span>
  376. <span
  377. data-titlebar-tab-title
  378. class="min-w-0 flex-1 overflow-hidden text-clip whitespace-nowrap outline-none leading-4"
  379. >
  380. {props.title}
  381. </span>
  382. </a>
  383. <div data-slot="tab-close" class="group-hover:bg-[var(--tab-bg)] group-data-[active=true]:bg-[var(--tab-bg)]">
  384. <IconButtonV2
  385. size="small"
  386. variant="ghost-muted"
  387. onPointerDown={(event) => {
  388. event.preventDefault()
  389. event.stopPropagation()
  390. }}
  391. onMouseDown={(event) => {
  392. event.preventDefault()
  393. event.stopPropagation()
  394. }}
  395. class="hover-reveal relative z-10 group-hover:opacity-100 group-data-[active=true]:opacity-100 group-data-[editing=true]:opacity-100"
  396. onClick={closeTab}
  397. icon={<IconV2 name="xmark-small" />}
  398. aria-label="Close tab"
  399. />
  400. </div>
  401. </div>
  402. )
  403. }