dialog-edit-project.tsx 9.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255
  1. import { Button } from "@opencode-ai/ui/button"
  2. import { useDialog } from "@opencode-ai/ui/context/dialog"
  3. import { Dialog } from "@opencode-ai/ui/dialog"
  4. import { TextField } from "@opencode-ai/ui/text-field"
  5. import { useMutation } from "@tanstack/solid-query"
  6. import { Icon } from "@opencode-ai/ui/icon"
  7. import { createMemo, For, Show } from "solid-js"
  8. import { createStore } from "solid-js/store"
  9. import { useGlobalSDK } from "@/context/global-sdk"
  10. import { useGlobalSync } from "@/context/global-sync"
  11. import { type LocalProject, getAvatarColors } from "@/context/layout"
  12. import { getFilename } from "@opencode-ai/shared/util/path"
  13. import { Avatar } from "@opencode-ai/ui/avatar"
  14. import { useLanguage } from "@/context/language"
  15. const AVATAR_COLOR_KEYS = ["pink", "mint", "orange", "purple", "cyan", "lime"] as const
  16. export function DialogEditProject(props: { project: LocalProject }) {
  17. const dialog = useDialog()
  18. const globalSDK = useGlobalSDK()
  19. const globalSync = useGlobalSync()
  20. const language = useLanguage()
  21. const folderName = createMemo(() => getFilename(props.project.worktree))
  22. const defaultName = createMemo(() => props.project.name || folderName())
  23. const [store, setStore] = createStore({
  24. name: defaultName(),
  25. color: props.project.icon?.color || "pink",
  26. iconUrl: props.project.icon?.override || "",
  27. startup: props.project.commands?.start ?? "",
  28. dragOver: false,
  29. iconHover: false,
  30. })
  31. let iconInput: HTMLInputElement | undefined
  32. function handleFileSelect(file: File) {
  33. if (!file.type.startsWith("image/")) return
  34. const reader = new FileReader()
  35. reader.onload = (e) => {
  36. setStore("iconUrl", e.target?.result as string)
  37. setStore("iconHover", false)
  38. }
  39. reader.readAsDataURL(file)
  40. }
  41. function handleDrop(e: DragEvent) {
  42. e.preventDefault()
  43. setStore("dragOver", false)
  44. const file = e.dataTransfer?.files[0]
  45. if (file) handleFileSelect(file)
  46. }
  47. function handleDragOver(e: DragEvent) {
  48. e.preventDefault()
  49. setStore("dragOver", true)
  50. }
  51. function handleDragLeave() {
  52. setStore("dragOver", false)
  53. }
  54. function handleInputChange(e: Event) {
  55. const input = e.target as HTMLInputElement
  56. const file = input.files?.[0]
  57. if (file) handleFileSelect(file)
  58. }
  59. function clearIcon() {
  60. setStore("iconUrl", "")
  61. }
  62. const saveMutation = useMutation(() => ({
  63. mutationFn: async () => {
  64. const name = store.name.trim() === folderName() ? "" : store.name.trim()
  65. const start = store.startup.trim()
  66. if (props.project.id && props.project.id !== "global") {
  67. await globalSDK.client.project.update({
  68. projectID: props.project.id,
  69. directory: props.project.worktree,
  70. name,
  71. icon: { color: store.color, override: store.iconUrl },
  72. commands: { start },
  73. })
  74. globalSync.project.icon(props.project.worktree, store.iconUrl || undefined)
  75. dialog.close()
  76. return
  77. }
  78. globalSync.project.meta(props.project.worktree, {
  79. name,
  80. icon: { color: store.color, override: store.iconUrl || undefined },
  81. commands: { start: start || undefined },
  82. })
  83. dialog.close()
  84. },
  85. }))
  86. function handleSubmit(e: SubmitEvent) {
  87. e.preventDefault()
  88. if (saveMutation.isPending) return
  89. saveMutation.mutate()
  90. }
  91. return (
  92. <Dialog title={language.t("dialog.project.edit.title")} class="w-full max-w-[480px] mx-auto">
  93. <form onSubmit={handleSubmit} class="flex flex-col gap-6 p-6 pt-0">
  94. <div class="flex flex-col gap-4">
  95. <TextField
  96. autofocus
  97. type="text"
  98. label={language.t("dialog.project.edit.name")}
  99. placeholder={folderName()}
  100. value={store.name}
  101. onChange={(v) => setStore("name", v)}
  102. />
  103. <div class="flex flex-col gap-2">
  104. <label class="text-12-medium text-text-weak">{language.t("dialog.project.edit.icon")}</label>
  105. <div class="flex gap-3 items-start">
  106. <div
  107. class="relative"
  108. onMouseEnter={() => setStore("iconHover", true)}
  109. onMouseLeave={() => setStore("iconHover", false)}
  110. >
  111. <div
  112. class="relative size-16 rounded-md transition-colors cursor-pointer"
  113. classList={{
  114. "border-text-interactive-base bg-surface-info-base/20": store.dragOver,
  115. "border-border-base hover:border-border-strong": !store.dragOver,
  116. "overflow-hidden": !!store.iconUrl,
  117. }}
  118. onDrop={handleDrop}
  119. onDragOver={handleDragOver}
  120. onDragLeave={handleDragLeave}
  121. onClick={() => {
  122. if (store.iconUrl && store.iconHover) {
  123. clearIcon()
  124. } else {
  125. iconInput?.click()
  126. }
  127. }}
  128. >
  129. <Show
  130. when={store.iconUrl}
  131. fallback={
  132. <div class="size-full flex items-center justify-center">
  133. <Avatar
  134. fallback={store.name || defaultName()}
  135. {...getAvatarColors(store.color)}
  136. class="size-full text-[32px]"
  137. />
  138. </div>
  139. }
  140. >
  141. <img
  142. src={store.iconUrl}
  143. alt={language.t("dialog.project.edit.icon.alt")}
  144. class="size-full object-cover"
  145. />
  146. </Show>
  147. </div>
  148. <div
  149. class="absolute inset-0 size-16 bg-surface-raised-stronger-non-alpha/90 rounded-[6px] z-10 pointer-events-none flex items-center justify-center transition-opacity"
  150. classList={{
  151. "opacity-100": store.iconHover && !store.iconUrl,
  152. "opacity-0": !(store.iconHover && !store.iconUrl),
  153. }}
  154. >
  155. <Icon name="cloud-upload" size="large" class="text-icon-on-interactive-base drop-shadow-sm" />
  156. </div>
  157. <div
  158. class="absolute inset-0 size-16 bg-surface-raised-stronger-non-alpha/90 rounded-[6px] z-10 pointer-events-none flex items-center justify-center transition-opacity"
  159. classList={{
  160. "opacity-100": store.iconHover && !!store.iconUrl,
  161. "opacity-0": !(store.iconHover && !!store.iconUrl),
  162. }}
  163. >
  164. <Icon name="trash" size="large" class="text-icon-on-interactive-base drop-shadow-sm" />
  165. </div>
  166. </div>
  167. <input
  168. id="icon-upload"
  169. ref={(el) => {
  170. iconInput = el
  171. }}
  172. type="file"
  173. accept="image/*"
  174. class="hidden"
  175. onChange={handleInputChange}
  176. />
  177. <div class="flex flex-col gap-1.5 text-12-regular text-text-weak self-center">
  178. <span>{language.t("dialog.project.edit.icon.hint")}</span>
  179. <span>{language.t("dialog.project.edit.icon.recommended")}</span>
  180. </div>
  181. </div>
  182. </div>
  183. <Show when={!store.iconUrl}>
  184. <div class="flex flex-col gap-2">
  185. <label class="text-12-medium text-text-weak">{language.t("dialog.project.edit.color")}</label>
  186. <div class="flex gap-1.5">
  187. <For each={AVATAR_COLOR_KEYS}>
  188. {(color) => (
  189. <button
  190. type="button"
  191. aria-label={language.t("dialog.project.edit.color.select", { color })}
  192. aria-pressed={store.color === color}
  193. classList={{
  194. "flex items-center justify-center size-10 p-0.5 rounded-lg overflow-hidden transition-colors cursor-default": true,
  195. "bg-transparent border-2 border-icon-strong-base hover:bg-surface-base-hover":
  196. store.color === color,
  197. "bg-transparent border border-transparent hover:bg-surface-base-hover hover:border-border-weak-base":
  198. store.color !== color,
  199. }}
  200. onClick={() => setStore("color", color)}
  201. >
  202. <Avatar
  203. fallback={store.name || defaultName()}
  204. {...getAvatarColors(color)}
  205. class="size-full rounded"
  206. />
  207. </button>
  208. )}
  209. </For>
  210. </div>
  211. </div>
  212. </Show>
  213. <TextField
  214. multiline
  215. label={language.t("dialog.project.edit.worktree.startup")}
  216. description={language.t("dialog.project.edit.worktree.startup.description")}
  217. placeholder={language.t("dialog.project.edit.worktree.startup.placeholder")}
  218. value={store.startup}
  219. onChange={(v) => setStore("startup", v)}
  220. spellcheck={false}
  221. class="max-h-14 w-full overflow-y-auto font-mono text-xs"
  222. />
  223. </div>
  224. <div class="flex justify-end gap-2">
  225. <Button type="button" variant="ghost" size="large" onClick={() => dialog.close()}>
  226. {language.t("common.cancel")}
  227. </Button>
  228. <Button type="submit" variant="primary" size="large" disabled={saveMutation.isPending}>
  229. {saveMutation.isPending ? language.t("common.saving") : language.t("common.save")}
  230. </Button>
  231. </div>
  232. </form>
  233. </Dialog>
  234. )
  235. }