session-header.tsx 28 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712
  1. import { AppIcon } from "@opencode-ai/ui/app-icon"
  2. import { Button } from "@opencode-ai/ui/button"
  3. import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu"
  4. import { Icon } from "@opencode-ai/ui/icon"
  5. import { IconButton } from "@opencode-ai/ui/icon-button"
  6. import { Keybind } from "@opencode-ai/ui/keybind"
  7. import { Popover } from "@opencode-ai/ui/popover"
  8. import { Spinner } from "@opencode-ai/ui/spinner"
  9. import { TextField } from "@opencode-ai/ui/text-field"
  10. import { showToast } from "@opencode-ai/ui/toast"
  11. import { Tooltip, TooltipKeybind } from "@opencode-ai/ui/tooltip"
  12. import { getFilename } from "@opencode-ai/util/path"
  13. import { useParams } from "@solidjs/router"
  14. import { createEffect, createMemo, For, onCleanup, Show } from "solid-js"
  15. import { createStore } from "solid-js/store"
  16. import { Portal } from "solid-js/web"
  17. import { useCommand } from "@/context/command"
  18. import { useGlobalSDK } from "@/context/global-sdk"
  19. import { useLanguage } from "@/context/language"
  20. import { useLayout } from "@/context/layout"
  21. import { usePlatform } from "@/context/platform"
  22. import { useServer } from "@/context/server"
  23. import { useSync } from "@/context/sync"
  24. import { decode64 } from "@/utils/base64"
  25. import { Persist, persisted } from "@/utils/persist"
  26. import { StatusPopover } from "../status-popover"
  27. const OPEN_APPS = [
  28. "vscode",
  29. "cursor",
  30. "zed",
  31. "textmate",
  32. "antigravity",
  33. "finder",
  34. "terminal",
  35. "iterm2",
  36. "ghostty",
  37. "warp",
  38. "xcode",
  39. "android-studio",
  40. "powershell",
  41. "sublime-text",
  42. ] as const
  43. type OpenApp = (typeof OPEN_APPS)[number]
  44. type OS = "macos" | "windows" | "linux" | "unknown"
  45. const MAC_APPS = [
  46. {
  47. id: "vscode",
  48. label: "VS Code",
  49. icon: "vscode",
  50. openWith: "Visual Studio Code",
  51. },
  52. { id: "cursor", label: "Cursor", icon: "cursor", openWith: "Cursor" },
  53. { id: "zed", label: "Zed", icon: "zed", openWith: "Zed" },
  54. { id: "textmate", label: "TextMate", icon: "textmate", openWith: "TextMate" },
  55. {
  56. id: "antigravity",
  57. label: "Antigravity",
  58. icon: "antigravity",
  59. openWith: "Antigravity",
  60. },
  61. { id: "terminal", label: "Terminal", icon: "terminal", openWith: "Terminal" },
  62. { id: "iterm2", label: "iTerm2", icon: "iterm2", openWith: "iTerm" },
  63. { id: "ghostty", label: "Ghostty", icon: "ghostty", openWith: "Ghostty" },
  64. { id: "warp", label: "Warp", icon: "warp", openWith: "Warp" },
  65. { id: "xcode", label: "Xcode", icon: "xcode", openWith: "Xcode" },
  66. {
  67. id: "android-studio",
  68. label: "Android Studio",
  69. icon: "android-studio",
  70. openWith: "Android Studio",
  71. },
  72. {
  73. id: "sublime-text",
  74. label: "Sublime Text",
  75. icon: "sublime-text",
  76. openWith: "Sublime Text",
  77. },
  78. ] as const
  79. const WINDOWS_APPS = [
  80. { id: "vscode", label: "VS Code", icon: "vscode", openWith: "code" },
  81. { id: "cursor", label: "Cursor", icon: "cursor", openWith: "cursor" },
  82. { id: "zed", label: "Zed", icon: "zed", openWith: "zed" },
  83. {
  84. id: "powershell",
  85. label: "PowerShell",
  86. icon: "powershell",
  87. openWith: "powershell",
  88. },
  89. {
  90. id: "sublime-text",
  91. label: "Sublime Text",
  92. icon: "sublime-text",
  93. openWith: "Sublime Text",
  94. },
  95. ] as const
  96. const LINUX_APPS = [
  97. { id: "vscode", label: "VS Code", icon: "vscode", openWith: "code" },
  98. { id: "cursor", label: "Cursor", icon: "cursor", openWith: "cursor" },
  99. { id: "zed", label: "Zed", icon: "zed", openWith: "zed" },
  100. {
  101. id: "sublime-text",
  102. label: "Sublime Text",
  103. icon: "sublime-text",
  104. openWith: "Sublime Text",
  105. },
  106. ] as const
  107. type OpenOption = (typeof MAC_APPS)[number] | (typeof WINDOWS_APPS)[number] | (typeof LINUX_APPS)[number]
  108. type OpenIcon = OpenApp | "file-explorer"
  109. const OPEN_ICON_BASE = new Set<OpenIcon>(["finder", "vscode", "cursor", "zed"])
  110. const openIconSize = (id: OpenIcon) => (OPEN_ICON_BASE.has(id) ? "size-4" : "size-[19px]")
  111. const detectOS = (platform: ReturnType<typeof usePlatform>): OS => {
  112. if (platform.platform === "desktop" && platform.os) return platform.os
  113. if (typeof navigator !== "object") return "unknown"
  114. const value = navigator.platform || navigator.userAgent
  115. if (/Mac/i.test(value)) return "macos"
  116. if (/Win/i.test(value)) return "windows"
  117. if (/Linux/i.test(value)) return "linux"
  118. return "unknown"
  119. }
  120. const showRequestError = (language: ReturnType<typeof useLanguage>, err: unknown) => {
  121. showToast({
  122. variant: "error",
  123. title: language.t("common.requestFailed"),
  124. description: err instanceof Error ? err.message : String(err),
  125. })
  126. }
  127. function useSessionShare(args: {
  128. globalSDK: ReturnType<typeof useGlobalSDK>
  129. currentSession: () =>
  130. | {
  131. id: string
  132. share?: {
  133. url?: string
  134. }
  135. }
  136. | undefined
  137. projectDirectory: () => string
  138. platform: ReturnType<typeof usePlatform>
  139. }) {
  140. const [state, setState] = createStore({
  141. share: false,
  142. unshare: false,
  143. copied: false,
  144. timer: undefined as number | undefined,
  145. })
  146. const shareUrl = createMemo(() => args.currentSession()?.share?.url)
  147. createEffect(() => {
  148. const url = shareUrl()
  149. if (url) return
  150. if (state.timer) window.clearTimeout(state.timer)
  151. setState({ copied: false, timer: undefined })
  152. })
  153. onCleanup(() => {
  154. if (state.timer) window.clearTimeout(state.timer)
  155. })
  156. const shareSession = () => {
  157. const session = args.currentSession()
  158. if (!session || state.share) return
  159. setState("share", true)
  160. args.globalSDK.client.session
  161. .share({ sessionID: session.id, directory: args.projectDirectory() })
  162. .catch((error) => {
  163. console.error("Failed to share session", error)
  164. })
  165. .finally(() => {
  166. setState("share", false)
  167. })
  168. }
  169. const unshareSession = () => {
  170. const session = args.currentSession()
  171. if (!session || state.unshare) return
  172. setState("unshare", true)
  173. args.globalSDK.client.session
  174. .unshare({ sessionID: session.id, directory: args.projectDirectory() })
  175. .catch((error) => {
  176. console.error("Failed to unshare session", error)
  177. })
  178. .finally(() => {
  179. setState("unshare", false)
  180. })
  181. }
  182. const copyLink = (onError: (error: unknown) => void) => {
  183. const url = shareUrl()
  184. if (!url) return
  185. navigator.clipboard
  186. .writeText(url)
  187. .then(() => {
  188. if (state.timer) window.clearTimeout(state.timer)
  189. setState("copied", true)
  190. const timer = window.setTimeout(() => {
  191. setState("copied", false)
  192. setState("timer", undefined)
  193. }, 3000)
  194. setState("timer", timer)
  195. })
  196. .catch(onError)
  197. }
  198. const viewShare = () => {
  199. const url = shareUrl()
  200. if (!url) return
  201. args.platform.openLink(url)
  202. }
  203. return { state, shareUrl, shareSession, unshareSession, copyLink, viewShare }
  204. }
  205. export function SessionHeader() {
  206. const globalSDK = useGlobalSDK()
  207. const layout = useLayout()
  208. const params = useParams()
  209. const command = useCommand()
  210. const server = useServer()
  211. const sync = useSync()
  212. const platform = usePlatform()
  213. const language = useLanguage()
  214. const projectDirectory = createMemo(() => decode64(params.dir) ?? "")
  215. const project = createMemo(() => {
  216. const directory = projectDirectory()
  217. if (!directory) return
  218. return layout.projects.list().find((p) => p.worktree === directory || p.sandboxes?.includes(directory))
  219. })
  220. const name = createMemo(() => {
  221. const current = project()
  222. if (current) return current.name || getFilename(current.worktree)
  223. return getFilename(projectDirectory())
  224. })
  225. const hotkey = createMemo(() => command.keybind("file.open"))
  226. const currentSession = createMemo(() => sync.data.session.find((s) => s.id === params.id))
  227. const shareEnabled = createMemo(() => sync.data.config.share !== "disabled")
  228. const showShare = createMemo(() => shareEnabled() && !!currentSession())
  229. const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`)
  230. const view = createMemo(() => layout.view(sessionKey))
  231. const os = createMemo(() => detectOS(platform))
  232. const [exists, setExists] = createStore<Partial<Record<OpenApp, boolean>>>({
  233. finder: true,
  234. })
  235. const apps = createMemo(() => {
  236. if (os() === "macos") return MAC_APPS
  237. if (os() === "windows") return WINDOWS_APPS
  238. return LINUX_APPS
  239. })
  240. const fileManager = createMemo(() => {
  241. if (os() === "macos") return { label: "Finder", icon: "finder" as const }
  242. if (os() === "windows") return { label: "File Explorer", icon: "file-explorer" as const }
  243. return { label: "File Manager", icon: "finder" as const }
  244. })
  245. createEffect(() => {
  246. if (platform.platform !== "desktop") return
  247. if (!platform.checkAppExists) return
  248. const list = apps()
  249. setExists(Object.fromEntries(list.map((app) => [app.id, undefined])) as Partial<Record<OpenApp, boolean>>)
  250. void Promise.all(
  251. list.map((app) =>
  252. Promise.resolve(platform.checkAppExists?.(app.openWith))
  253. .then((value) => Boolean(value))
  254. .catch(() => false)
  255. .then((ok) => {
  256. console.debug(`[session-header] App "${app.label}" (${app.openWith}): ${ok ? "exists" : "does not exist"}`)
  257. return [app.id, ok] as const
  258. }),
  259. ),
  260. ).then((entries) => {
  261. setExists(Object.fromEntries(entries) as Partial<Record<OpenApp, boolean>>)
  262. })
  263. })
  264. const options = createMemo(() => {
  265. return [
  266. { id: "finder", label: fileManager().label, icon: fileManager().icon },
  267. ...apps().filter((app) => exists[app.id]),
  268. ] as const
  269. })
  270. const [prefs, setPrefs] = persisted(Persist.global("open.app"), createStore({ app: "finder" as OpenApp }))
  271. const [menu, setMenu] = createStore({ open: false })
  272. const [openRequest, setOpenRequest] = createStore({
  273. app: undefined as OpenApp | undefined,
  274. })
  275. const canOpen = createMemo(() => platform.platform === "desktop" && !!platform.openPath && server.isLocal())
  276. const current = createMemo(() => options().find((o) => o.id === prefs.app) ?? options()[0])
  277. const opening = createMemo(() => openRequest.app !== undefined)
  278. createEffect(() => {
  279. const value = prefs.app
  280. if (options().some((o) => o.id === value)) return
  281. setPrefs("app", options()[0]?.id ?? "finder")
  282. })
  283. const openDir = (app: OpenApp) => {
  284. if (opening() || !canOpen() || !platform.openPath) return
  285. const directory = projectDirectory()
  286. if (!directory) return
  287. const item = options().find((o) => o.id === app)
  288. const openWith = item && "openWith" in item ? item.openWith : undefined
  289. setOpenRequest("app", app)
  290. platform
  291. .openPath(directory, openWith)
  292. .catch((err: unknown) => showRequestError(language, err))
  293. .finally(() => {
  294. setOpenRequest("app", undefined)
  295. })
  296. }
  297. const copyPath = () => {
  298. const directory = projectDirectory()
  299. if (!directory) return
  300. navigator.clipboard
  301. .writeText(directory)
  302. .then(() => {
  303. showToast({
  304. variant: "success",
  305. icon: "circle-check",
  306. title: language.t("session.share.copy.copied"),
  307. description: directory,
  308. })
  309. })
  310. .catch((err: unknown) => showRequestError(language, err))
  311. }
  312. const share = useSessionShare({
  313. globalSDK,
  314. currentSession,
  315. projectDirectory,
  316. platform,
  317. })
  318. const centerMount = createMemo(() => document.getElementById("opencode-titlebar-center"))
  319. const rightMount = createMemo(() => document.getElementById("opencode-titlebar-right"))
  320. return (
  321. <>
  322. <Show when={centerMount()}>
  323. {(mount) => (
  324. <Portal mount={mount()}>
  325. <Button
  326. type="button"
  327. variant="ghost"
  328. size="small"
  329. class="hidden md:flex w-[240px] max-w-full min-w-0 pl-0.5 pr-2 items-center gap-2 justify-between rounded-md border border-border-weak-base bg-surface-panel shadow-none cursor-default"
  330. onClick={() => command.trigger("file.open")}
  331. aria-label={language.t("session.header.searchFiles")}
  332. >
  333. <div class="flex min-w-0 flex-1 items-center gap-1.5 overflow-visible">
  334. <Icon name="magnifying-glass" size="small" class="icon-base shrink-0 size-4" />
  335. <span class="flex-1 min-w-0 text-12-regular text-text-weak truncate text-left">
  336. {language.t("session.header.search.placeholder", {
  337. project: name(),
  338. })}
  339. </span>
  340. </div>
  341. <Show when={hotkey()}>
  342. {(keybind) => (
  343. <Keybind class="shrink-0 !border-0 !bg-transparent !shadow-none px-0">{keybind()}</Keybind>
  344. )}
  345. </Show>
  346. </Button>
  347. </Portal>
  348. )}
  349. </Show>
  350. <Show when={rightMount()}>
  351. {(mount) => (
  352. <Portal mount={mount()}>
  353. <div class="flex items-center gap-2">
  354. <StatusPopover />
  355. <Show when={projectDirectory()}>
  356. <div class="hidden xl:flex items-center">
  357. <Show
  358. when={canOpen()}
  359. fallback={
  360. <div class="flex h-[24px] box-border items-center rounded-md border border-border-weak-base bg-surface-panel overflow-hidden">
  361. <Button
  362. variant="ghost"
  363. class="rounded-none h-full py-0 pr-3 pl-0.5 gap-1.5 border-none shadow-none"
  364. onClick={copyPath}
  365. aria-label={language.t("session.header.open.copyPath")}
  366. >
  367. <Icon name="copy" size="small" class="text-icon-base" />
  368. <span class="text-12-regular text-text-strong">
  369. {language.t("session.header.open.copyPath")}
  370. </span>
  371. </Button>
  372. </div>
  373. }
  374. >
  375. <div class="flex items-center">
  376. <div class="flex h-[24px] box-border items-center rounded-md border border-border-weak-base bg-surface-panel overflow-hidden">
  377. <Button
  378. variant="ghost"
  379. class="rounded-none h-full py-0 pr-3 pl-0.5 gap-1.5 border-none shadow-none disabled:!cursor-default"
  380. classList={{
  381. "bg-surface-raised-base-active": opening(),
  382. }}
  383. onClick={() => openDir(current().id)}
  384. disabled={opening()}
  385. aria-label={language.t("session.header.open.ariaLabel", { app: current().label })}
  386. >
  387. <div class="flex size-5 shrink-0 items-center justify-center">
  388. <Show
  389. when={opening()}
  390. fallback={<AppIcon id={current().icon} class={openIconSize(current().icon)} />}
  391. >
  392. <Spinner class="size-3.5 text-icon-base" />
  393. </Show>
  394. </div>
  395. <span class="text-12-regular text-text-strong">{language.t("common.open")}</span>
  396. </Button>
  397. <div class="self-stretch w-px bg-border-weak-base" />
  398. <DropdownMenu
  399. gutter={4}
  400. placement="bottom-end"
  401. open={menu.open}
  402. onOpenChange={(open) => setMenu("open", open)}
  403. >
  404. <DropdownMenu.Trigger
  405. as={IconButton}
  406. icon="chevron-down"
  407. variant="ghost"
  408. disabled={opening()}
  409. class="rounded-none h-full w-[24px] p-0 border-none shadow-none data-[expanded]:bg-surface-raised-base-active disabled:!cursor-default"
  410. classList={{
  411. "bg-surface-raised-base-active": opening(),
  412. }}
  413. aria-label={language.t("session.header.open.menu")}
  414. />
  415. <DropdownMenu.Portal>
  416. <DropdownMenu.Content>
  417. <DropdownMenu.Group>
  418. <DropdownMenu.GroupLabel>{language.t("session.header.openIn")}</DropdownMenu.GroupLabel>
  419. <DropdownMenu.RadioGroup
  420. value={current().id}
  421. onChange={(value) => {
  422. if (!OPEN_APPS.includes(value as OpenApp)) return
  423. setPrefs("app", value as OpenApp)
  424. }}
  425. >
  426. <For each={options()}>
  427. {(o) => (
  428. <DropdownMenu.RadioItem
  429. value={o.id}
  430. disabled={opening()}
  431. onSelect={() => {
  432. setMenu("open", false)
  433. openDir(o.id)
  434. }}
  435. >
  436. <div class="flex size-5 shrink-0 items-center justify-center">
  437. <AppIcon id={o.icon} class={openIconSize(o.icon)} />
  438. </div>
  439. <DropdownMenu.ItemLabel>{o.label}</DropdownMenu.ItemLabel>
  440. <DropdownMenu.ItemIndicator>
  441. <Icon name="check-small" size="small" class="text-icon-weak" />
  442. </DropdownMenu.ItemIndicator>
  443. </DropdownMenu.RadioItem>
  444. )}
  445. </For>
  446. </DropdownMenu.RadioGroup>
  447. </DropdownMenu.Group>
  448. <DropdownMenu.Separator />
  449. <DropdownMenu.Item
  450. onSelect={() => {
  451. setMenu("open", false)
  452. copyPath()
  453. }}
  454. >
  455. <div class="flex size-5 shrink-0 items-center justify-center">
  456. <Icon name="copy" size="small" class="text-icon-weak" />
  457. </div>
  458. <DropdownMenu.ItemLabel>
  459. {language.t("session.header.open.copyPath")}
  460. </DropdownMenu.ItemLabel>
  461. </DropdownMenu.Item>
  462. </DropdownMenu.Content>
  463. </DropdownMenu.Portal>
  464. </DropdownMenu>
  465. </div>
  466. </div>
  467. </Show>
  468. </div>
  469. </Show>
  470. <Show when={showShare()}>
  471. <div class="flex items-center">
  472. <Popover
  473. title={language.t("session.share.popover.title")}
  474. description={
  475. share.shareUrl()
  476. ? language.t("session.share.popover.description.shared")
  477. : language.t("session.share.popover.description.unshared")
  478. }
  479. gutter={4}
  480. placement="bottom-end"
  481. shift={-64}
  482. class="rounded-xl [&_[data-slot=popover-close-button]]:hidden"
  483. triggerAs={Button}
  484. triggerProps={{
  485. variant: "ghost",
  486. class:
  487. "rounded-md h-[24px] px-3 border border-border-weak-base bg-surface-panel shadow-none data-[expanded]:bg-surface-base-active",
  488. classList: {
  489. "rounded-r-none": share.shareUrl() !== undefined,
  490. "border-r-0": share.shareUrl() !== undefined,
  491. },
  492. style: { scale: 1 },
  493. }}
  494. trigger={<span class="text-12-regular">{language.t("session.share.action.share")}</span>}
  495. >
  496. <div class="flex flex-col gap-2">
  497. <Show
  498. when={share.shareUrl()}
  499. fallback={
  500. <div class="flex">
  501. <Button
  502. size="large"
  503. variant="primary"
  504. class="w-1/2"
  505. onClick={share.shareSession}
  506. disabled={share.state.share}
  507. >
  508. {share.state.share
  509. ? language.t("session.share.action.publishing")
  510. : language.t("session.share.action.publish")}
  511. </Button>
  512. </div>
  513. }
  514. >
  515. <div class="flex flex-col gap-2">
  516. <TextField
  517. value={share.shareUrl() ?? ""}
  518. readOnly
  519. copyable
  520. copyKind="link"
  521. tabIndex={-1}
  522. class="w-full"
  523. />
  524. <div class="grid grid-cols-2 gap-2">
  525. <Button
  526. size="large"
  527. variant="secondary"
  528. class="w-full shadow-none border border-border-weak-base"
  529. onClick={share.unshareSession}
  530. disabled={share.state.unshare}
  531. >
  532. {share.state.unshare
  533. ? language.t("session.share.action.unpublishing")
  534. : language.t("session.share.action.unpublish")}
  535. </Button>
  536. <Button
  537. size="large"
  538. variant="primary"
  539. class="w-full"
  540. onClick={share.viewShare}
  541. disabled={share.state.unshare}
  542. >
  543. {language.t("session.share.action.view")}
  544. </Button>
  545. </div>
  546. </div>
  547. </Show>
  548. </div>
  549. </Popover>
  550. <Show when={share.shareUrl()} fallback={<div aria-hidden="true" />}>
  551. <Tooltip
  552. value={
  553. share.state.copied
  554. ? language.t("session.share.copy.copied")
  555. : language.t("session.share.copy.copyLink")
  556. }
  557. placement="top"
  558. gutter={8}
  559. >
  560. <IconButton
  561. icon={share.state.copied ? "check" : "link"}
  562. variant="ghost"
  563. class="rounded-l-none h-[24px] border border-border-weak-base bg-surface-panel shadow-none"
  564. onClick={() => share.copyLink((error) => showRequestError(language, error))}
  565. disabled={share.state.unshare}
  566. aria-label={
  567. share.state.copied
  568. ? language.t("session.share.copy.copied")
  569. : language.t("session.share.copy.copyLink")
  570. }
  571. />
  572. </Tooltip>
  573. </Show>
  574. </div>
  575. </Show>
  576. <div class="flex items-center gap-1">
  577. <div class="hidden md:flex items-center gap-1 shrink-0">
  578. <TooltipKeybind
  579. title={language.t("command.terminal.toggle")}
  580. keybind={command.keybind("terminal.toggle")}
  581. >
  582. <Button
  583. variant="ghost"
  584. class="group/terminal-toggle titlebar-icon w-8 h-6 p-0 box-border"
  585. onClick={() => view().terminal.toggle()}
  586. aria-label={language.t("command.terminal.toggle")}
  587. aria-expanded={view().terminal.opened()}
  588. aria-controls="terminal-panel"
  589. >
  590. <div class="relative flex items-center justify-center size-4 [&>*]:absolute [&>*]:inset-0">
  591. <Icon
  592. size="small"
  593. name={view().terminal.opened() ? "layout-bottom-partial" : "layout-bottom"}
  594. class="group-hover/terminal-toggle:hidden"
  595. />
  596. <Icon
  597. size="small"
  598. name="layout-bottom-partial"
  599. class="hidden group-hover/terminal-toggle:inline-block"
  600. />
  601. <Icon
  602. size="small"
  603. name={view().terminal.opened() ? "layout-bottom" : "layout-bottom-partial"}
  604. class="hidden group-active/terminal-toggle:inline-block"
  605. />
  606. </div>
  607. </Button>
  608. </TooltipKeybind>
  609. <TooltipKeybind
  610. title={language.t("command.review.toggle")}
  611. keybind={command.keybind("review.toggle")}
  612. >
  613. <Button
  614. variant="ghost"
  615. class="group/review-toggle titlebar-icon w-8 h-6 p-0 box-border"
  616. onClick={() => view().reviewPanel.toggle()}
  617. aria-label={language.t("command.review.toggle")}
  618. aria-expanded={view().reviewPanel.opened()}
  619. aria-controls="review-panel"
  620. >
  621. <div class="relative flex items-center justify-center size-4 [&>*]:absolute [&>*]:inset-0">
  622. <Icon
  623. size="small"
  624. name={view().reviewPanel.opened() ? "layout-right-partial" : "layout-right"}
  625. class="group-hover/review-toggle:hidden"
  626. />
  627. <Icon
  628. size="small"
  629. name="layout-right-partial"
  630. class="hidden group-hover/review-toggle:inline-block"
  631. />
  632. <Icon
  633. size="small"
  634. name={view().reviewPanel.opened() ? "layout-right" : "layout-right-partial"}
  635. class="hidden group-active/review-toggle:inline-block"
  636. />
  637. </div>
  638. </Button>
  639. </TooltipKeybind>
  640. <TooltipKeybind
  641. title={language.t("command.fileTree.toggle")}
  642. keybind={command.keybind("fileTree.toggle")}
  643. >
  644. <Button
  645. variant="ghost"
  646. class="titlebar-icon w-8 h-6 p-0 box-border"
  647. onClick={() => layout.fileTree.toggle()}
  648. aria-label={language.t("command.fileTree.toggle")}
  649. aria-expanded={layout.fileTree.opened()}
  650. aria-controls="file-tree-panel"
  651. >
  652. <div class="relative flex items-center justify-center size-4">
  653. <Icon
  654. size="small"
  655. name={layout.fileTree.opened() ? "file-tree-active" : "file-tree"}
  656. classList={{
  657. "text-icon-strong": layout.fileTree.opened(),
  658. "text-icon-weak": !layout.fileTree.opened(),
  659. }}
  660. />
  661. </div>
  662. </Button>
  663. </TooltipKeybind>
  664. </div>
  665. </div>
  666. </div>
  667. </Portal>
  668. )}
  669. </Show>
  670. </>
  671. )
  672. }