session-header.tsx 21 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503
  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 { Spinner } from "@opencode-ai/ui/spinner"
  8. import { showToast } from "@opencode-ai/ui/toast"
  9. import { Tooltip, TooltipKeybind } from "@opencode-ai/ui/tooltip"
  10. import { getFilename } from "@opencode-ai/shared/util/path"
  11. import { createEffect, createMemo, createSignal, For, onMount, Show } from "solid-js"
  12. import { createStore } from "solid-js/store"
  13. import { Portal } from "solid-js/web"
  14. import { useCommand } from "@/context/command"
  15. import { useLanguage } from "@/context/language"
  16. import { useLayout } from "@/context/layout"
  17. import { usePlatform } from "@/context/platform"
  18. import { useServer } from "@/context/server"
  19. import { useSettings } from "@/context/settings"
  20. import { useSync } from "@/context/sync"
  21. import { useTerminal } from "@/context/terminal"
  22. import { focusTerminalById } from "@/pages/session/helpers"
  23. import { useSessionLayout } from "@/pages/session/session-layout"
  24. import { messageAgentColor } from "@/utils/agent"
  25. import { decode64 } from "@/utils/base64"
  26. import { Persist, persisted } from "@/utils/persist"
  27. import { StatusPopover } from "../status-popover"
  28. const OPEN_APPS = [
  29. "vscode",
  30. "cursor",
  31. "zed",
  32. "textmate",
  33. "antigravity",
  34. "finder",
  35. "terminal",
  36. "iterm2",
  37. "ghostty",
  38. "warp",
  39. "xcode",
  40. "android-studio",
  41. "powershell",
  42. "sublime-text",
  43. ] as const
  44. type OpenApp = (typeof OPEN_APPS)[number]
  45. type OS = "macos" | "windows" | "linux" | "unknown"
  46. const MAC_APPS = [
  47. {
  48. id: "vscode",
  49. label: "session.header.open.app.vscode",
  50. icon: "vscode",
  51. openWith: "Visual Studio Code",
  52. },
  53. { id: "cursor", label: "session.header.open.app.cursor", icon: "cursor", openWith: "Cursor" },
  54. { id: "zed", label: "session.header.open.app.zed", icon: "zed", openWith: "Zed" },
  55. { id: "textmate", label: "session.header.open.app.textmate", icon: "textmate", openWith: "TextMate" },
  56. {
  57. id: "antigravity",
  58. label: "session.header.open.app.antigravity",
  59. icon: "antigravity",
  60. openWith: "Antigravity",
  61. },
  62. { id: "terminal", label: "session.header.open.app.terminal", icon: "terminal", openWith: "Terminal" },
  63. { id: "iterm2", label: "session.header.open.app.iterm2", icon: "iterm2", openWith: "iTerm" },
  64. { id: "ghostty", label: "session.header.open.app.ghostty", icon: "ghostty", openWith: "Ghostty" },
  65. { id: "warp", label: "session.header.open.app.warp", icon: "warp", openWith: "Warp" },
  66. { id: "xcode", label: "session.header.open.app.xcode", icon: "xcode", openWith: "Xcode" },
  67. {
  68. id: "android-studio",
  69. label: "session.header.open.app.androidStudio",
  70. icon: "android-studio",
  71. openWith: "Android Studio",
  72. },
  73. {
  74. id: "sublime-text",
  75. label: "session.header.open.app.sublimeText",
  76. icon: "sublime-text",
  77. openWith: "Sublime Text",
  78. },
  79. ] as const
  80. const WINDOWS_APPS = [
  81. { id: "vscode", label: "session.header.open.app.vscode", icon: "vscode", openWith: "code" },
  82. { id: "cursor", label: "session.header.open.app.cursor", icon: "cursor", openWith: "cursor" },
  83. { id: "zed", label: "session.header.open.app.zed", icon: "zed", openWith: "zed" },
  84. {
  85. id: "powershell",
  86. label: "session.header.open.app.powershell",
  87. icon: "powershell",
  88. openWith: "powershell",
  89. },
  90. {
  91. id: "sublime-text",
  92. label: "session.header.open.app.sublimeText",
  93. icon: "sublime-text",
  94. openWith: "Sublime Text",
  95. },
  96. ] as const
  97. const LINUX_APPS = [
  98. { id: "vscode", label: "session.header.open.app.vscode", icon: "vscode", openWith: "code" },
  99. { id: "cursor", label: "session.header.open.app.cursor", icon: "cursor", openWith: "cursor" },
  100. { id: "zed", label: "session.header.open.app.zed", icon: "zed", openWith: "zed" },
  101. {
  102. id: "sublime-text",
  103. label: "session.header.open.app.sublimeText",
  104. icon: "sublime-text",
  105. openWith: "Sublime Text",
  106. },
  107. ] as const
  108. const detectOS = (platform: ReturnType<typeof usePlatform>): OS => {
  109. if (platform.platform === "desktop" && platform.os) return platform.os
  110. if (typeof navigator !== "object") return "unknown"
  111. const value = navigator.platform || navigator.userAgent
  112. if (/Mac/i.test(value)) return "macos"
  113. if (/Win/i.test(value)) return "windows"
  114. if (/Linux/i.test(value)) return "linux"
  115. return "unknown"
  116. }
  117. const showRequestError = (language: ReturnType<typeof useLanguage>, err: unknown) => {
  118. showToast({
  119. variant: "error",
  120. title: language.t("common.requestFailed"),
  121. description: err instanceof Error ? err.message : String(err),
  122. })
  123. }
  124. export function SessionHeader() {
  125. const layout = useLayout()
  126. const command = useCommand()
  127. const server = useServer()
  128. const platform = usePlatform()
  129. const language = useLanguage()
  130. const settings = useSettings()
  131. const sync = useSync()
  132. const terminal = useTerminal()
  133. const { params, view } = useSessionLayout()
  134. const projectDirectory = createMemo(() => decode64(params.dir) ?? "")
  135. const project = createMemo(() => {
  136. const directory = projectDirectory()
  137. if (!directory) return
  138. return layout.projects.list().find((p) => p.worktree === directory || p.sandboxes?.includes(directory))
  139. })
  140. const name = createMemo(() => {
  141. const current = project()
  142. if (current) return current.name || getFilename(current.worktree)
  143. return getFilename(projectDirectory())
  144. })
  145. const hotkey = createMemo(() => command.keybind("file.open"))
  146. const os = createMemo(() => detectOS(platform))
  147. const isDesktopBeta = platform.platform === "desktop" && import.meta.env.VITE_OPENCODE_CHANNEL === "beta"
  148. const search = createMemo(() => !isDesktopBeta || settings.general.showSearch())
  149. const tree = createMemo(() => !isDesktopBeta || settings.general.showFileTree())
  150. const term = createMemo(() => !isDesktopBeta || settings.general.showTerminal())
  151. const status = createMemo(() => !isDesktopBeta || settings.general.showStatus())
  152. const [exists, setExists] = createStore<Partial<Record<OpenApp, boolean>>>({
  153. finder: true,
  154. })
  155. const apps = createMemo(() => {
  156. if (os() === "macos") return MAC_APPS
  157. if (os() === "windows") return WINDOWS_APPS
  158. return LINUX_APPS
  159. })
  160. const fileManager = createMemo(() => {
  161. if (os() === "macos") return { label: "session.header.open.finder", icon: "finder" as const }
  162. if (os() === "windows") return { label: "session.header.open.fileExplorer", icon: "file-explorer" as const }
  163. return { label: "session.header.open.fileManager", icon: "finder" as const }
  164. })
  165. createEffect(() => {
  166. if (platform.platform !== "desktop") return
  167. if (!platform.checkAppExists) return
  168. const list = apps()
  169. setExists(Object.fromEntries(list.map((app) => [app.id, undefined])) as Partial<Record<OpenApp, boolean>>)
  170. void Promise.all(
  171. list.map((app) =>
  172. Promise.resolve(platform.checkAppExists?.(app.openWith))
  173. .then((value) => Boolean(value))
  174. .catch(() => false)
  175. .then((ok) => [app.id, ok] as const),
  176. ),
  177. ).then((entries) => {
  178. setExists(Object.fromEntries(entries) as Partial<Record<OpenApp, boolean>>)
  179. })
  180. })
  181. const options = createMemo(() => {
  182. return [
  183. { id: "finder", label: language.t(fileManager().label), icon: fileManager().icon },
  184. ...apps()
  185. .filter((app) => exists[app.id])
  186. .map((app) => ({ ...app, label: language.t(app.label) })),
  187. ] as const
  188. })
  189. const toggleTerminal = () => {
  190. const next = !view().terminal.opened()
  191. view().terminal.toggle()
  192. if (!next) return
  193. const id = terminal.active()
  194. if (!id) return
  195. focusTerminalById(id)
  196. }
  197. const [prefs, setPrefs] = persisted(Persist.global("open.app"), createStore({ app: "finder" as OpenApp }))
  198. const [menu, setMenu] = createStore({ open: false })
  199. const [openRequest, setOpenRequest] = createStore({
  200. app: undefined as OpenApp | undefined,
  201. })
  202. const canOpen = createMemo(() => platform.platform === "desktop" && !!platform.openPath && server.isLocal())
  203. const current = createMemo(
  204. () =>
  205. options().find((o) => o.id === prefs.app) ??
  206. options()[0] ??
  207. ({ id: "finder", label: fileManager().label, icon: fileManager().icon } as const),
  208. )
  209. const opening = createMemo(() => openRequest.app !== undefined)
  210. const tint = createMemo(() =>
  211. messageAgentColor(params.id ? sync.data.message[params.id] : undefined, sync.data.agent),
  212. )
  213. const selectApp = (app: OpenApp) => {
  214. if (!options().some((item) => item.id === app)) return
  215. setPrefs("app", app)
  216. }
  217. const openDir = (app: OpenApp) => {
  218. if (opening() || !canOpen() || !platform.openPath) return
  219. const directory = projectDirectory()
  220. if (!directory) return
  221. const item = options().find((o) => o.id === app)
  222. const openWith = item && "openWith" in item ? item.openWith : undefined
  223. setOpenRequest("app", app)
  224. platform
  225. .openPath(directory, openWith)
  226. .catch((err: unknown) => showRequestError(language, err))
  227. .finally(() => {
  228. setOpenRequest("app", undefined)
  229. })
  230. }
  231. const copyPath = () => {
  232. const directory = projectDirectory()
  233. if (!directory) return
  234. navigator.clipboard
  235. .writeText(directory)
  236. .then(() => {
  237. showToast({
  238. variant: "success",
  239. icon: "circle-check",
  240. title: language.t("session.share.copy.copied"),
  241. description: directory,
  242. })
  243. })
  244. .catch((err: unknown) => showRequestError(language, err))
  245. }
  246. const [centerMount, setCenterMount] = createSignal<HTMLElement | null>(null)
  247. const [rightMount, setRightMount] = createSignal<HTMLElement | null>(null)
  248. onMount(() => {
  249. setCenterMount(document.getElementById("opencode-titlebar-center"))
  250. setRightMount(document.getElementById("opencode-titlebar-right"))
  251. })
  252. return (
  253. <>
  254. <Show when={search() && centerMount()}>
  255. {(mount) => (
  256. <Portal mount={mount()}>
  257. <Button
  258. type="button"
  259. variant="ghost"
  260. size="small"
  261. class="hidden md:flex w-[240px] max-w-full min-w-0 items-center gap-2 justify-between rounded-md border border-border-weak-base bg-surface-panel shadow-none cursor-default"
  262. onClick={() => command.trigger("file.open")}
  263. aria-label={language.t("session.header.searchFiles")}
  264. >
  265. <div class="flex min-w-0 flex-1 items-center overflow-visible">
  266. <span class="flex-1 min-w-0 text-12-regular text-text-weak truncate text-left">
  267. {language.t("session.header.search.placeholder", {
  268. project: name(),
  269. })}
  270. </span>
  271. </div>
  272. <Show when={hotkey()}>
  273. {(keybind) => (
  274. <Keybind class="shrink-0 !border-0 !bg-transparent !shadow-none px-0 text-text-weaker">
  275. {keybind()}
  276. </Keybind>
  277. )}
  278. </Show>
  279. </Button>
  280. </Portal>
  281. )}
  282. </Show>
  283. <Show when={rightMount()}>
  284. {(mount) => (
  285. <Portal mount={mount()}>
  286. <div class="flex items-center gap-2">
  287. <Show when={projectDirectory()}>
  288. <div class="hidden xl:flex items-center">
  289. <Show
  290. when={canOpen()}
  291. fallback={
  292. <div class="flex h-[24px] box-border items-center rounded-md border border-border-weak-base bg-surface-panel overflow-hidden">
  293. <Button
  294. variant="ghost"
  295. class="rounded-none h-full py-0 pr-3 pl-0.5 gap-1.5 border-none shadow-none"
  296. onClick={copyPath}
  297. aria-label={language.t("session.header.open.copyPath")}
  298. >
  299. <Icon name="copy" size="small" class="text-icon-base" />
  300. <span class="text-12-regular text-text-strong">
  301. {language.t("session.header.open.copyPath")}
  302. </span>
  303. </Button>
  304. </div>
  305. }
  306. >
  307. <div class="flex items-center">
  308. <div class="flex h-[24px] box-border items-center rounded-md border border-border-weak-base bg-surface-panel overflow-hidden">
  309. <Button
  310. variant="ghost"
  311. class="rounded-none h-full px-0.5 border-none shadow-none disabled:!cursor-default"
  312. classList={{
  313. "bg-surface-raised-base-active": opening(),
  314. }}
  315. onClick={() => openDir(current().id)}
  316. disabled={opening()}
  317. aria-label={language.t("session.header.open.ariaLabel", { app: current().label })}
  318. >
  319. <div class="flex size-5 shrink-0 items-center justify-center [&_[data-component=app-icon]]:size-5">
  320. <Show when={opening()} fallback={<AppIcon id={current().icon} />}>
  321. <Spinner class="size-3.5" style={{ color: tint() ?? "var(--icon-base)" }} />
  322. </Show>
  323. </div>
  324. </Button>
  325. <DropdownMenu
  326. gutter={4}
  327. placement="bottom-end"
  328. open={menu.open}
  329. onOpenChange={(open) => setMenu("open", open)}
  330. >
  331. <DropdownMenu.Trigger
  332. as={IconButton}
  333. icon="chevron-down"
  334. variant="ghost"
  335. disabled={opening()}
  336. class="rounded-none h-full w-[20px] p-0 border-none shadow-none data-[expanded]:bg-surface-raised-base-active disabled:!cursor-default"
  337. classList={{
  338. "bg-surface-raised-base-active": opening(),
  339. }}
  340. aria-label={language.t("session.header.open.menu")}
  341. />
  342. <DropdownMenu.Portal>
  343. <DropdownMenu.Content class="[&_[data-slot=dropdown-menu-item]]:pl-1 [&_[data-slot=dropdown-menu-radio-item]]:pl-1 [&_[data-slot=dropdown-menu-radio-item]+[data-slot=dropdown-menu-radio-item]]:mt-1">
  344. <DropdownMenu.Group>
  345. <DropdownMenu.GroupLabel class="!px-1 !py-1">
  346. {language.t("session.header.openIn")}
  347. </DropdownMenu.GroupLabel>
  348. <DropdownMenu.RadioGroup
  349. class="mt-1"
  350. value={current().id}
  351. onChange={(value) => {
  352. if (!OPEN_APPS.includes(value as OpenApp)) return
  353. selectApp(value as OpenApp)
  354. }}
  355. >
  356. <For each={options()}>
  357. {(o) => (
  358. <DropdownMenu.RadioItem
  359. value={o.id}
  360. disabled={opening()}
  361. onSelect={() => {
  362. setMenu("open", false)
  363. openDir(o.id)
  364. }}
  365. >
  366. <div class="flex size-5 shrink-0 items-center justify-center [&_[data-component=app-icon]]:size-5">
  367. <AppIcon id={o.icon} />
  368. </div>
  369. <DropdownMenu.ItemLabel>{o.label}</DropdownMenu.ItemLabel>
  370. <DropdownMenu.ItemIndicator>
  371. <Icon name="check-small" size="small" class="text-icon-weak" />
  372. </DropdownMenu.ItemIndicator>
  373. </DropdownMenu.RadioItem>
  374. )}
  375. </For>
  376. </DropdownMenu.RadioGroup>
  377. </DropdownMenu.Group>
  378. <DropdownMenu.Separator />
  379. <DropdownMenu.Item
  380. onSelect={() => {
  381. setMenu("open", false)
  382. copyPath()
  383. }}
  384. >
  385. <div class="flex size-5 shrink-0 items-center justify-center">
  386. <Icon name="copy" size="small" class="text-icon-weak" />
  387. </div>
  388. <DropdownMenu.ItemLabel>
  389. {language.t("session.header.open.copyPath")}
  390. </DropdownMenu.ItemLabel>
  391. </DropdownMenu.Item>
  392. </DropdownMenu.Content>
  393. </DropdownMenu.Portal>
  394. </DropdownMenu>
  395. </div>
  396. </div>
  397. </Show>
  398. </div>
  399. </Show>
  400. <div class="flex items-center gap-1">
  401. <Show when={status()}>
  402. <Tooltip placement="bottom" value={language.t("status.popover.trigger")}>
  403. <StatusPopover />
  404. </Tooltip>
  405. </Show>
  406. <Show when={term()}>
  407. <TooltipKeybind
  408. title={language.t("command.terminal.toggle")}
  409. keybind={command.keybind("terminal.toggle")}
  410. >
  411. <Button
  412. variant="ghost"
  413. class="group/terminal-toggle titlebar-icon w-8 h-6 p-0 box-border shrink-0"
  414. onClick={toggleTerminal}
  415. aria-label={language.t("command.terminal.toggle")}
  416. aria-expanded={view().terminal.opened()}
  417. aria-controls="terminal-panel"
  418. >
  419. <Icon size="small" name={view().terminal.opened() ? "terminal-active" : "terminal"} />
  420. </Button>
  421. </TooltipKeybind>
  422. </Show>
  423. <div class="hidden md:flex items-center gap-1 shrink-0">
  424. <TooltipKeybind
  425. title={language.t("command.review.toggle")}
  426. keybind={command.keybind("review.toggle")}
  427. >
  428. <Button
  429. variant="ghost"
  430. class="group/review-toggle titlebar-icon w-8 h-6 p-0 box-border"
  431. onClick={() => view().reviewPanel.toggle()}
  432. aria-label={language.t("command.review.toggle")}
  433. aria-expanded={view().reviewPanel.opened()}
  434. aria-controls="review-panel"
  435. >
  436. <Icon size="small" name={view().reviewPanel.opened() ? "review-active" : "review"} />
  437. </Button>
  438. </TooltipKeybind>
  439. <Show when={tree()}>
  440. <TooltipKeybind
  441. title={language.t("command.fileTree.toggle")}
  442. keybind={command.keybind("fileTree.toggle")}
  443. >
  444. <Button
  445. variant="ghost"
  446. class="titlebar-icon w-8 h-6 p-0 box-border"
  447. onClick={() => layout.fileTree.toggle()}
  448. aria-label={language.t("command.fileTree.toggle")}
  449. aria-expanded={layout.fileTree.opened()}
  450. aria-controls="file-tree-panel"
  451. >
  452. <div class="relative flex items-center justify-center size-4">
  453. <Icon
  454. size="small"
  455. name={layout.fileTree.opened() ? "file-tree-active" : "file-tree"}
  456. classList={{
  457. "text-icon-strong": layout.fileTree.opened(),
  458. "text-icon-weak": !layout.fileTree.opened(),
  459. }}
  460. />
  461. </div>
  462. </Button>
  463. </TooltipKeybind>
  464. </Show>
  465. </div>
  466. </div>
  467. </div>
  468. </Portal>
  469. )}
  470. </Show>
  471. </>
  472. )
  473. }