feat(app): align edit project modal with v2 style (#36213)
Co-authored-by: Brendan Allan <git@brendonovich.dev>
This commit is contained in:
co-authored by
Brendan Allan
parent
9bbf7918de
commit
17cd4a8c32
@@ -0,0 +1,156 @@
|
|||||||
|
import { ButtonV2 } from "@opencode-ai/ui/v2/button-v2"
|
||||||
|
import { Dialog, DialogBody, DialogFooter, DialogHeader, DialogTitle } from "@opencode-ai/ui/v2/dialog-v2"
|
||||||
|
import { DividerV2 } from "@opencode-ai/ui/v2/divider-v2"
|
||||||
|
import { Field } from "@opencode-ai/ui/v2/field-v2"
|
||||||
|
import { Icon } from "@opencode-ai/ui/v2/icon"
|
||||||
|
import { ProjectAvatar, PROJECT_AVATAR_VARIANTS } from "@opencode-ai/ui/v2/project-avatar-v2"
|
||||||
|
import { TextareaV2 } from "@opencode-ai/ui/v2/textarea-v2"
|
||||||
|
import { TextInputV2 } from "@opencode-ai/ui/v2/text-input-v2"
|
||||||
|
import { For, Show } from "solid-js"
|
||||||
|
import { useLanguage } from "@/context/language"
|
||||||
|
import { getProjectAvatarVariant, type LocalProject } from "@/context/layout"
|
||||||
|
import { ServerConnection } from "@/context/server"
|
||||||
|
import { getProjectAvatarSource } from "@/pages/layout/helpers"
|
||||||
|
import { createEditProjectModel } from "./edit-project"
|
||||||
|
|
||||||
|
export function DialogEditProjectV2(props: { project: LocalProject; server: ServerConnection.Any }) {
|
||||||
|
const language = useLanguage()
|
||||||
|
const model = createEditProjectModel(props)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog fit>
|
||||||
|
<form onSubmit={model.submit} class="contents">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>{language.t("dialog.project.edit.title")}</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
<DividerV2 />
|
||||||
|
<DialogBody class="flex max-h-[min(560px,calc(100vh-160px))] w-full flex-col gap-6 overflow-y-auto px-4 pt-4 pb-1">
|
||||||
|
<Field>
|
||||||
|
<Field.Label>{language.t("dialog.project.edit.name")}</Field.Label>
|
||||||
|
<TextInputV2
|
||||||
|
autofocus
|
||||||
|
appearance="large"
|
||||||
|
class="!w-full"
|
||||||
|
value={model.store.name}
|
||||||
|
placeholder={model.folderName()}
|
||||||
|
onInput={(event) => model.setStore("name", event.currentTarget.value)}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<div class="flex w-full flex-col gap-2">
|
||||||
|
<div class="select-none text-[13px] font-[530] leading-none tracking-[-0.04px] text-v2-text-text-base">
|
||||||
|
{language.t("dialog.project.edit.icon")}
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label={language.t("dialog.project.edit.icon.alt")}
|
||||||
|
class="relative size-16 shrink-0 cursor-pointer overflow-hidden rounded-[6px] outline outline-1 outline-transparent transition-[background-color,outline-color] focus-visible:outline-v2-border-border-focus"
|
||||||
|
classList={{
|
||||||
|
"bg-v2-overlay-simple-overlay-hover outline-v2-border-border-focus": model.store.dragOver,
|
||||||
|
}}
|
||||||
|
onMouseEnter={() => model.setStore("iconHover", true)}
|
||||||
|
onMouseLeave={() => model.setStore("iconHover", false)}
|
||||||
|
onDrop={model.drop}
|
||||||
|
onDragOver={model.dragOver}
|
||||||
|
onDragLeave={model.dragLeave}
|
||||||
|
onClick={model.iconClick}
|
||||||
|
>
|
||||||
|
<ProjectAvatar
|
||||||
|
fallback={model.store.name || model.defaultName()}
|
||||||
|
src={getProjectAvatarSource(props.project.id, {
|
||||||
|
color: model.store.color,
|
||||||
|
url: props.project.icon?.url,
|
||||||
|
override: model.store.iconOverride,
|
||||||
|
})}
|
||||||
|
variant={getProjectAvatarVariant(model.store.color)}
|
||||||
|
class="!size-16 [&_[data-slot=project-avatar-surface]]:!rounded-[6px] [&_[data-slot=project-avatar-surface]]:!text-[32px]"
|
||||||
|
/>
|
||||||
|
<span
|
||||||
|
class="pointer-events-none absolute inset-0 flex items-center justify-center rounded-[6px] bg-v2-background-bg-contrast/80 text-v2-icon-icon-contrast backdrop-blur-[2px] transition-opacity"
|
||||||
|
classList={{
|
||||||
|
"opacity-100": model.store.iconHover,
|
||||||
|
"opacity-0": !model.store.iconHover,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Icon name={model.store.iconOverride ? "close" : "outline-share"} />
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
<input
|
||||||
|
ref={(element) => {
|
||||||
|
model.setIconInput(element)
|
||||||
|
}}
|
||||||
|
type="file"
|
||||||
|
accept="image/*"
|
||||||
|
class="hidden"
|
||||||
|
onChange={model.inputChange}
|
||||||
|
/>
|
||||||
|
<div class="flex select-none flex-col gap-[6px] text-[11px] font-[440] leading-none tracking-[0.05px] text-v2-text-text-muted">
|
||||||
|
<span>{language.t("dialog.project.edit.icon.hint")}</span>
|
||||||
|
<span>{language.t("dialog.project.edit.icon.recommended")}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Show when={!model.store.iconOverride}>
|
||||||
|
<div class="flex w-full flex-col gap-2">
|
||||||
|
<div class="select-none text-[13px] font-[530] leading-none tracking-[-0.04px] text-v2-text-text-base">
|
||||||
|
{language.t("dialog.project.edit.color")}
|
||||||
|
</div>
|
||||||
|
<div class="-ml-1 flex gap-1.5">
|
||||||
|
<For each={PROJECT_AVATAR_VARIANTS}>
|
||||||
|
{(color) => (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label={language.t("dialog.project.edit.color.select", { color })}
|
||||||
|
aria-pressed={getProjectAvatarVariant(model.store.color) === color}
|
||||||
|
class="flex size-8 items-center justify-center rounded-[10px] p-1 outline outline-1 outline-transparent transition-[background-color,outline-color] hover:bg-v2-overlay-simple-overlay-hover focus-visible:outline-v2-border-border-focus"
|
||||||
|
classList={{
|
||||||
|
"bg-v2-overlay-simple-overlay-hover [box-shadow:inset_0_0_0_2px_var(--v2-border-border-focus)]":
|
||||||
|
getProjectAvatarVariant(model.store.color) === color,
|
||||||
|
}}
|
||||||
|
onClick={() => {
|
||||||
|
if (getProjectAvatarVariant(model.store.color) === color && !props.project.icon?.url) return
|
||||||
|
model.setStore(
|
||||||
|
"color",
|
||||||
|
getProjectAvatarVariant(model.store.color) === color ? undefined : color,
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ProjectAvatar
|
||||||
|
fallback={model.store.name || model.defaultName()}
|
||||||
|
variant={getProjectAvatarVariant(color)}
|
||||||
|
class="!size-6 [&_[data-slot=project-avatar-surface]]:!rounded-[6px]"
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</For>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
|
||||||
|
<Field>
|
||||||
|
<Field.Label>{language.t("dialog.project.edit.worktree.startup")}</Field.Label>
|
||||||
|
<Field.Prefix>{language.t("dialog.project.edit.worktree.startup.description")}</Field.Prefix>
|
||||||
|
<TextareaV2
|
||||||
|
class="!w-full [&_[data-slot=textarea-v2-textarea]]:font-mono"
|
||||||
|
rows={3}
|
||||||
|
value={model.store.startup}
|
||||||
|
placeholder={language.t("dialog.project.edit.worktree.startup.placeholder")}
|
||||||
|
spellcheck={false}
|
||||||
|
onInput={(event) => model.setStore("startup", event.currentTarget.value)}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
</DialogBody>
|
||||||
|
<DialogFooter>
|
||||||
|
<ButtonV2 type="button" variant="neutral" disabled={model.save.isPending} onClick={model.close}>
|
||||||
|
{language.t("common.cancel")}
|
||||||
|
</ButtonV2>
|
||||||
|
<ButtonV2 type="submit" variant="contrast" disabled={model.save.isPending}>
|
||||||
|
{model.save.isPending ? language.t("common.saving") : language.t("common.save")}
|
||||||
|
</ButtonV2>
|
||||||
|
</DialogFooter>
|
||||||
|
</form>
|
||||||
|
</Dialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,123 +1,32 @@
|
|||||||
import { Button } from "@opencode-ai/ui/button"
|
import { Button } from "@opencode-ai/ui/button"
|
||||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
|
||||||
import { Dialog } from "@opencode-ai/ui/dialog"
|
import { Dialog } from "@opencode-ai/ui/dialog"
|
||||||
import { TextField } from "@opencode-ai/ui/text-field"
|
import { TextField } from "@opencode-ai/ui/text-field"
|
||||||
import { useMutation } from "@tanstack/solid-query"
|
|
||||||
import { Icon } from "@opencode-ai/ui/icon"
|
import { Icon } from "@opencode-ai/ui/icon"
|
||||||
import { createMemo, For, Show } from "solid-js"
|
import { For, Show } from "solid-js"
|
||||||
import { createStore } from "solid-js/store"
|
|
||||||
import { type LocalProject, getAvatarColors } from "@/context/layout"
|
import { type LocalProject, getAvatarColors } from "@/context/layout"
|
||||||
import { getFilename } from "@opencode-ai/core/util/path"
|
|
||||||
import { Avatar } from "@opencode-ai/ui/avatar"
|
import { Avatar } from "@opencode-ai/ui/avatar"
|
||||||
import { useLanguage } from "@/context/language"
|
import { useLanguage } from "@/context/language"
|
||||||
import { getProjectAvatarSource } from "@/pages/layout/helpers"
|
import { getProjectAvatarSource } from "@/pages/layout/helpers"
|
||||||
import { ServerConnection } from "@/context/server"
|
import { ServerConnection } from "@/context/server"
|
||||||
import { useGlobal } from "@/context/global"
|
import { createEditProjectModel } from "./edit-project"
|
||||||
|
|
||||||
const AVATAR_COLOR_KEYS = ["pink", "mint", "orange", "purple", "cyan", "lime"] as const
|
const AVATAR_COLOR_KEYS = ["pink", "mint", "orange", "purple", "cyan", "lime"] as const
|
||||||
|
|
||||||
export function DialogEditProject(props: { project: LocalProject; server: ServerConnection.Any }) {
|
export function DialogEditProject(props: { project: LocalProject; server: ServerConnection.Any }) {
|
||||||
const dialog = useDialog()
|
|
||||||
const global = useGlobal()
|
|
||||||
const language = useLanguage()
|
const language = useLanguage()
|
||||||
const serverCtx = createMemo(() => global.ensureServerCtx(props.server))
|
const model = createEditProjectModel(props)
|
||||||
const serverSDK = () => serverCtx().sdk
|
|
||||||
const serverSync = () => serverCtx().sync
|
|
||||||
|
|
||||||
const folderName = createMemo(() => getFilename(props.project.worktree))
|
|
||||||
const defaultName = createMemo(() => props.project.name || folderName())
|
|
||||||
|
|
||||||
const [store, setStore] = createStore({
|
|
||||||
name: defaultName(),
|
|
||||||
color: props.project.icon?.color,
|
|
||||||
iconOverride: props.project.icon?.override,
|
|
||||||
startup: props.project.commands?.start ?? "",
|
|
||||||
dragOver: false,
|
|
||||||
iconHover: false,
|
|
||||||
})
|
|
||||||
|
|
||||||
let iconInput: HTMLInputElement | undefined
|
|
||||||
|
|
||||||
function handleFileSelect(file: File) {
|
|
||||||
if (!file.type.startsWith("image/")) return
|
|
||||||
const reader = new FileReader()
|
|
||||||
reader.onload = (e) => {
|
|
||||||
setStore("iconOverride", e.target?.result as string)
|
|
||||||
setStore("iconHover", false)
|
|
||||||
}
|
|
||||||
reader.readAsDataURL(file)
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleDrop(e: DragEvent) {
|
|
||||||
e.preventDefault()
|
|
||||||
setStore("dragOver", false)
|
|
||||||
const file = e.dataTransfer?.files[0]
|
|
||||||
if (file) handleFileSelect(file)
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleDragOver(e: DragEvent) {
|
|
||||||
e.preventDefault()
|
|
||||||
setStore("dragOver", true)
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleDragLeave() {
|
|
||||||
setStore("dragOver", false)
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleInputChange(e: Event) {
|
|
||||||
const input = e.target as HTMLInputElement
|
|
||||||
const file = input.files?.[0]
|
|
||||||
if (file) handleFileSelect(file)
|
|
||||||
}
|
|
||||||
|
|
||||||
function clearIcon() {
|
|
||||||
setStore("iconOverride", "")
|
|
||||||
}
|
|
||||||
|
|
||||||
const saveMutation = useMutation(() => ({
|
|
||||||
mutationFn: async () => {
|
|
||||||
const name = store.name.trim() === folderName() ? "" : store.name.trim()
|
|
||||||
const start = store.startup.trim()
|
|
||||||
|
|
||||||
if (props.project.id && props.project.id !== "global") {
|
|
||||||
await serverSDK().client.project.update({
|
|
||||||
projectID: props.project.id,
|
|
||||||
directory: props.project.worktree,
|
|
||||||
name,
|
|
||||||
icon: { color: store.color || "", override: store.iconOverride || "" },
|
|
||||||
commands: { start },
|
|
||||||
})
|
|
||||||
serverSync().project.icon(props.project.worktree, store.iconOverride || undefined)
|
|
||||||
dialog.close()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
serverSync().project.meta(props.project.worktree, {
|
|
||||||
name,
|
|
||||||
icon: { color: store.color || undefined, override: store.iconOverride || undefined },
|
|
||||||
commands: { start: start || undefined },
|
|
||||||
})
|
|
||||||
dialog.close()
|
|
||||||
},
|
|
||||||
}))
|
|
||||||
|
|
||||||
function handleSubmit(e: SubmitEvent) {
|
|
||||||
e.preventDefault()
|
|
||||||
if (saveMutation.isPending) return
|
|
||||||
saveMutation.mutate()
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog title={language.t("dialog.project.edit.title")} class="w-full max-w-[480px] mx-auto">
|
<Dialog title={language.t("dialog.project.edit.title")} class="w-full max-w-[480px] mx-auto">
|
||||||
<form onSubmit={handleSubmit} class="flex flex-col gap-6 p-6 pt-0">
|
<form onSubmit={model.submit} class="flex flex-col gap-6 p-6 pt-0">
|
||||||
<div class="flex flex-col gap-4">
|
<div class="flex flex-col gap-4">
|
||||||
<TextField
|
<TextField
|
||||||
autofocus
|
autofocus
|
||||||
type="text"
|
type="text"
|
||||||
label={language.t("dialog.project.edit.name")}
|
label={language.t("dialog.project.edit.name")}
|
||||||
placeholder={folderName()}
|
placeholder={model.folderName()}
|
||||||
value={store.name}
|
value={model.store.name}
|
||||||
onChange={(v) => setStore("name", v)}
|
onChange={(v) => model.setStore("name", v)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div class="flex flex-col gap-2">
|
<div class="flex flex-col gap-2">
|
||||||
@@ -125,38 +34,32 @@ export function DialogEditProject(props: { project: LocalProject; server: Server
|
|||||||
<div class="flex gap-3 items-start">
|
<div class="flex gap-3 items-start">
|
||||||
<div
|
<div
|
||||||
class="relative"
|
class="relative"
|
||||||
onMouseEnter={() => setStore("iconHover", true)}
|
onMouseEnter={() => model.setStore("iconHover", true)}
|
||||||
onMouseLeave={() => setStore("iconHover", false)}
|
onMouseLeave={() => model.setStore("iconHover", false)}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
class="relative size-16 rounded-md transition-colors cursor-pointer"
|
class="relative size-16 rounded-md transition-colors cursor-pointer"
|
||||||
classList={{
|
classList={{
|
||||||
"border-text-interactive-base bg-surface-info-base/20": store.dragOver,
|
"border-text-interactive-base bg-surface-info-base/20": model.store.dragOver,
|
||||||
"border-border-base hover:border-border-strong": !store.dragOver,
|
"border-border-base hover:border-border-strong": !model.store.dragOver,
|
||||||
"overflow-hidden": !!store.iconOverride,
|
"overflow-hidden": !!model.store.iconOverride,
|
||||||
}}
|
|
||||||
onDrop={handleDrop}
|
|
||||||
onDragOver={handleDragOver}
|
|
||||||
onDragLeave={handleDragLeave}
|
|
||||||
onClick={() => {
|
|
||||||
if (store.iconOverride && store.iconHover) {
|
|
||||||
clearIcon()
|
|
||||||
} else {
|
|
||||||
iconInput?.click()
|
|
||||||
}
|
|
||||||
}}
|
}}
|
||||||
|
onDrop={model.drop}
|
||||||
|
onDragOver={model.dragOver}
|
||||||
|
onDragLeave={model.dragLeave}
|
||||||
|
onClick={model.iconClick}
|
||||||
>
|
>
|
||||||
<Show
|
<Show
|
||||||
when={getProjectAvatarSource(props.project.id, {
|
when={getProjectAvatarSource(props.project.id, {
|
||||||
color: store.color,
|
color: model.store.color,
|
||||||
url: props.project.icon?.url,
|
url: props.project.icon?.url,
|
||||||
override: store.iconOverride,
|
override: model.store.iconOverride,
|
||||||
})}
|
})}
|
||||||
fallback={
|
fallback={
|
||||||
<div class="size-full flex items-center justify-center">
|
<div class="size-full flex items-center justify-center">
|
||||||
<Avatar
|
<Avatar
|
||||||
fallback={store.name || defaultName()}
|
fallback={model.store.name || model.defaultName()}
|
||||||
{...getAvatarColors(store.color)}
|
{...getAvatarColors(model.store.color)}
|
||||||
class="size-full text-[32px]"
|
class="size-full text-[32px]"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -174,8 +77,8 @@ export function DialogEditProject(props: { project: LocalProject; server: Server
|
|||||||
<div
|
<div
|
||||||
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"
|
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"
|
||||||
classList={{
|
classList={{
|
||||||
"opacity-100": store.iconHover && !store.iconOverride,
|
"opacity-100": model.store.iconHover && !model.store.iconOverride,
|
||||||
"opacity-0": !(store.iconHover && !store.iconOverride),
|
"opacity-0": !(model.store.iconHover && !model.store.iconOverride),
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Icon name="cloud-upload" size="large" class="text-icon-on-interactive-base drop-shadow-sm" />
|
<Icon name="cloud-upload" size="large" class="text-icon-on-interactive-base drop-shadow-sm" />
|
||||||
@@ -183,8 +86,8 @@ export function DialogEditProject(props: { project: LocalProject; server: Server
|
|||||||
<div
|
<div
|
||||||
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"
|
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"
|
||||||
classList={{
|
classList={{
|
||||||
"opacity-100": store.iconHover && !!store.iconOverride,
|
"opacity-100": model.store.iconHover && !!model.store.iconOverride,
|
||||||
"opacity-0": !(store.iconHover && !!store.iconOverride),
|
"opacity-0": !(model.store.iconHover && !!model.store.iconOverride),
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Icon name="trash" size="large" class="text-icon-on-interactive-base drop-shadow-sm" />
|
<Icon name="trash" size="large" class="text-icon-on-interactive-base drop-shadow-sm" />
|
||||||
@@ -193,12 +96,12 @@ export function DialogEditProject(props: { project: LocalProject; server: Server
|
|||||||
<input
|
<input
|
||||||
id="icon-upload"
|
id="icon-upload"
|
||||||
ref={(el) => {
|
ref={(el) => {
|
||||||
iconInput = el
|
model.setIconInput(el)
|
||||||
}}
|
}}
|
||||||
type="file"
|
type="file"
|
||||||
accept="image/*"
|
accept="image/*"
|
||||||
class="hidden"
|
class="hidden"
|
||||||
onChange={handleInputChange}
|
onChange={model.inputChange}
|
||||||
/>
|
/>
|
||||||
<div class="flex flex-col gap-1.5 text-12-regular text-text-weak self-center">
|
<div class="flex flex-col gap-1.5 text-12-regular text-text-weak self-center">
|
||||||
<span>{language.t("dialog.project.edit.icon.hint")}</span>
|
<span>{language.t("dialog.project.edit.icon.hint")}</span>
|
||||||
@@ -207,7 +110,7 @@ export function DialogEditProject(props: { project: LocalProject; server: Server
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Show when={!store.iconOverride}>
|
<Show when={!model.store.iconOverride}>
|
||||||
<div class="flex flex-col gap-2">
|
<div class="flex flex-col gap-2">
|
||||||
<label class="text-12-medium text-text-weak">{language.t("dialog.project.edit.color")}</label>
|
<label class="text-12-medium text-text-weak">{language.t("dialog.project.edit.color")}</label>
|
||||||
<div class="flex gap-1.5">
|
<div class="flex gap-1.5">
|
||||||
@@ -216,21 +119,21 @@ export function DialogEditProject(props: { project: LocalProject; server: Server
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
aria-label={language.t("dialog.project.edit.color.select", { color })}
|
aria-label={language.t("dialog.project.edit.color.select", { color })}
|
||||||
aria-pressed={store.color === color}
|
aria-pressed={model.store.color === color}
|
||||||
classList={{
|
classList={{
|
||||||
"flex items-center justify-center size-10 p-0.5 rounded-lg overflow-hidden transition-colors cursor-default": true,
|
"flex items-center justify-center size-10 p-0.5 rounded-lg overflow-hidden transition-colors cursor-default": true,
|
||||||
"bg-transparent border-2 border-icon-strong-base hover:bg-surface-base-hover":
|
"bg-transparent border-2 border-icon-strong-base hover:bg-surface-base-hover":
|
||||||
store.color === color,
|
model.store.color === color,
|
||||||
"bg-transparent border border-transparent hover:bg-surface-base-hover hover:border-border-weak-base":
|
"bg-transparent border border-transparent hover:bg-surface-base-hover hover:border-border-weak-base":
|
||||||
store.color !== color,
|
model.store.color !== color,
|
||||||
}}
|
}}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (store.color === color && !props.project.icon?.url) return
|
if (model.store.color === color && !props.project.icon?.url) return
|
||||||
setStore("color", store.color === color ? undefined : color)
|
model.setStore("color", model.store.color === color ? undefined : color)
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Avatar
|
<Avatar
|
||||||
fallback={store.name || defaultName()}
|
fallback={model.store.name || model.defaultName()}
|
||||||
{...getAvatarColors(color)}
|
{...getAvatarColors(color)}
|
||||||
class="size-full rounded"
|
class="size-full rounded"
|
||||||
/>
|
/>
|
||||||
@@ -246,19 +149,19 @@ export function DialogEditProject(props: { project: LocalProject; server: Server
|
|||||||
label={language.t("dialog.project.edit.worktree.startup")}
|
label={language.t("dialog.project.edit.worktree.startup")}
|
||||||
description={language.t("dialog.project.edit.worktree.startup.description")}
|
description={language.t("dialog.project.edit.worktree.startup.description")}
|
||||||
placeholder={language.t("dialog.project.edit.worktree.startup.placeholder")}
|
placeholder={language.t("dialog.project.edit.worktree.startup.placeholder")}
|
||||||
value={store.startup}
|
value={model.store.startup}
|
||||||
onChange={(v) => setStore("startup", v)}
|
onChange={(v) => model.setStore("startup", v)}
|
||||||
spellcheck={false}
|
spellcheck={false}
|
||||||
class="max-h-14 w-full overflow-y-auto font-mono text-xs"
|
class="max-h-14 w-full overflow-y-auto font-mono text-xs"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex justify-end gap-2">
|
<div class="flex justify-end gap-2">
|
||||||
<Button type="button" variant="ghost" size="large" onClick={() => dialog.close()}>
|
<Button type="button" variant="ghost" size="large" onClick={model.close}>
|
||||||
{language.t("common.cancel")}
|
{language.t("common.cancel")}
|
||||||
</Button>
|
</Button>
|
||||||
<Button type="submit" variant="primary" size="large" disabled={saveMutation.isPending}>
|
<Button type="submit" variant="primary" size="large" disabled={model.save.isPending}>
|
||||||
{saveMutation.isPending ? language.t("common.saving") : language.t("common.save")}
|
{model.save.isPending ? language.t("common.saving") : language.t("common.save")}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
@@ -0,0 +1,119 @@
|
|||||||
|
import { getFilename } from "@opencode-ai/core/util/path"
|
||||||
|
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||||
|
import { useMutation } from "@tanstack/solid-query"
|
||||||
|
import { createMemo } from "solid-js"
|
||||||
|
import { createStore } from "solid-js/store"
|
||||||
|
import { useGlobal } from "@/context/global"
|
||||||
|
import { type LocalProject } from "@/context/layout"
|
||||||
|
import { ServerConnection } from "@/context/server"
|
||||||
|
|
||||||
|
export function createEditProjectModel(props: { project: LocalProject; server: ServerConnection.Any }) {
|
||||||
|
const dialog = useDialog()
|
||||||
|
const global = useGlobal()
|
||||||
|
const serverCtx = createMemo(() => global.ensureServerCtx(props.server))
|
||||||
|
const folderName = createMemo(() => getFilename(props.project.worktree))
|
||||||
|
const defaultName = createMemo(() => props.project.name || folderName())
|
||||||
|
const [store, setStore] = createStore({
|
||||||
|
name: defaultName(),
|
||||||
|
color: props.project.icon?.color,
|
||||||
|
iconOverride: props.project.icon?.override,
|
||||||
|
startup: props.project.commands?.start ?? "",
|
||||||
|
dragOver: false,
|
||||||
|
iconHover: false,
|
||||||
|
})
|
||||||
|
let iconInput: HTMLInputElement | undefined
|
||||||
|
|
||||||
|
function selectFile(file: File) {
|
||||||
|
if (!file.type.startsWith("image/")) return
|
||||||
|
const reader = new FileReader()
|
||||||
|
reader.onload = (event) => {
|
||||||
|
const result = event.target?.result
|
||||||
|
if (typeof result !== "string") return
|
||||||
|
setStore("iconOverride", result)
|
||||||
|
setStore("iconHover", false)
|
||||||
|
}
|
||||||
|
reader.readAsDataURL(file)
|
||||||
|
}
|
||||||
|
|
||||||
|
function drop(event: DragEvent) {
|
||||||
|
event.preventDefault()
|
||||||
|
setStore("dragOver", false)
|
||||||
|
const file = event.dataTransfer?.files[0]
|
||||||
|
if (file) selectFile(file)
|
||||||
|
}
|
||||||
|
|
||||||
|
function dragOver(event: DragEvent) {
|
||||||
|
event.preventDefault()
|
||||||
|
setStore("dragOver", true)
|
||||||
|
}
|
||||||
|
|
||||||
|
function dragLeave() {
|
||||||
|
setStore("dragOver", false)
|
||||||
|
}
|
||||||
|
|
||||||
|
function inputChange(event: Event) {
|
||||||
|
const file = (event.currentTarget as HTMLInputElement).files?.[0]
|
||||||
|
if (file) selectFile(file)
|
||||||
|
}
|
||||||
|
|
||||||
|
function iconClick() {
|
||||||
|
if (store.iconOverride && store.iconHover) {
|
||||||
|
setStore("iconOverride", "")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
iconInput?.click()
|
||||||
|
}
|
||||||
|
|
||||||
|
const save = useMutation(() => ({
|
||||||
|
mutationFn: async () => {
|
||||||
|
const name = store.name.trim() === folderName() ? "" : store.name.trim()
|
||||||
|
const start = store.startup.trim()
|
||||||
|
|
||||||
|
if (props.project.id && props.project.id !== "global") {
|
||||||
|
await serverCtx().sdk.client.project.update({
|
||||||
|
projectID: props.project.id,
|
||||||
|
directory: props.project.worktree,
|
||||||
|
name,
|
||||||
|
icon: { color: store.color || "", override: store.iconOverride || "" },
|
||||||
|
commands: { start },
|
||||||
|
})
|
||||||
|
serverCtx().sync.project.icon(props.project.worktree, store.iconOverride || undefined)
|
||||||
|
dialog.close()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
serverCtx().sync.project.meta(props.project.worktree, {
|
||||||
|
name,
|
||||||
|
icon: { color: store.color || undefined, override: store.iconOverride || undefined },
|
||||||
|
commands: { start: start || undefined },
|
||||||
|
})
|
||||||
|
dialog.close()
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
|
||||||
|
function submit(event: SubmitEvent) {
|
||||||
|
event.preventDefault()
|
||||||
|
if (save.isPending) return
|
||||||
|
save.mutate()
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
store,
|
||||||
|
setStore,
|
||||||
|
folderName,
|
||||||
|
defaultName,
|
||||||
|
save,
|
||||||
|
submit,
|
||||||
|
drop,
|
||||||
|
dragOver,
|
||||||
|
dragLeave,
|
||||||
|
inputChange,
|
||||||
|
iconClick,
|
||||||
|
close() {
|
||||||
|
dialog.close()
|
||||||
|
},
|
||||||
|
setIconInput(input: HTMLInputElement) {
|
||||||
|
iconInput = input
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -47,12 +47,20 @@ export function getAvatarColors(key?: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function getProjectAvatarVariant(key?: string): ProjectAvatarVariant {
|
export function getProjectAvatarVariant(key?: string): ProjectAvatarVariant {
|
||||||
if (key === "orange") return "orange"
|
|
||||||
if (key === "pink") return "pink"
|
|
||||||
if (key === "cyan") return "cyan"
|
|
||||||
if (key === "purple") return "purple"
|
|
||||||
if (key === "mint") return "cyan"
|
if (key === "mint") return "cyan"
|
||||||
if (key === "lime") return "green"
|
if (key === "lime") return "green"
|
||||||
|
if (
|
||||||
|
key === "orange" ||
|
||||||
|
key === "yellow" ||
|
||||||
|
key === "cyan" ||
|
||||||
|
key === "green" ||
|
||||||
|
key === "red" ||
|
||||||
|
key === "pink" ||
|
||||||
|
key === "blue" ||
|
||||||
|
key === "purple" ||
|
||||||
|
key === "gray"
|
||||||
|
)
|
||||||
|
return key
|
||||||
return "gray"
|
return "gray"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -466,8 +466,8 @@ export function NewHome() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function editProject(conn: ServerConnection.Any, project: LocalProject) {
|
function editProject(conn: ServerConnection.Any, project: LocalProject) {
|
||||||
void import("@/components/dialog-edit-project").then((x) => {
|
void import("@/components/dialog-edit-project-v2").then((x) => {
|
||||||
dialog.show(() => <x.DialogEditProject server={conn} project={project} />)
|
void dialog.show(() => <x.DialogEditProjectV2 server={conn} project={project} />)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -129,6 +129,10 @@ const icons = {
|
|||||||
viewBox: "0 0 16 16",
|
viewBox: "0 0 16 16",
|
||||||
body: `<path d="M13.5555 6.66656V2.44434H9.33326M13.5555 2.44434L7.99993 7.99989M13.5555 9.33324V13.5555C13.5555 13.5555 12.7599 13.5555 11.7777 13.5555H2.44438C2.44438 13.5555 2.44438 12.7599 2.44438 11.7777V4.22213C2.44438 3.2399 2.44434 2.44435 2.44434 2.44435H6.66661" stroke="currentColor"/>`,
|
body: `<path d="M13.5555 6.66656V2.44434H9.33326M13.5555 2.44434L7.99993 7.99989M13.5555 9.33324V13.5555C13.5555 13.5555 12.7599 13.5555 11.7777 13.5555H2.44438C2.44438 13.5555 2.44438 12.7599 2.44438 11.7777V4.22213C2.44438 3.2399 2.44434 2.44435 2.44434 2.44435H6.66661" stroke="currentColor"/>`,
|
||||||
},
|
},
|
||||||
|
"outline-share": {
|
||||||
|
viewBox: "0 0 16 16",
|
||||||
|
body: `<path d="M13.5554 10.4445V13.5556C13.5554 13.5556 12.7599 13.5556 11.7777 13.5556H4.22211C3.23989 13.5556 2.44434 13.5556 2.44434 13.5556V10.4445M4.88878 5.55557L7.99989 2.44446L11.111 5.55557M7.99989 2.44446L7.99989 9.11112" stroke="currentColor"/>`,
|
||||||
|
},
|
||||||
reset: {
|
reset: {
|
||||||
viewBox: "0 0 20 20",
|
viewBox: "0 0 20 20",
|
||||||
body: `<path d="M5.83333 4.16406L2.5 7.4974L5.83333 10.8307M3.33333 7.4974H17.9167V15.4141H10" stroke="currentColor" stroke-linecap="square"/>`,
|
body: `<path d="M5.83333 4.16406L2.5 7.4974L5.83333 10.8307M3.33333 7.4974H17.9167V15.4141H10" stroke="currentColor" stroke-linecap="square"/>`,
|
||||||
|
|||||||
@@ -38,7 +38,6 @@ export interface ProjectAvatarProps extends ComponentProps<"div"> {
|
|||||||
|
|
||||||
export function ProjectAvatar(props: ProjectAvatarProps) {
|
export function ProjectAvatar(props: ProjectAvatarProps) {
|
||||||
const [split, rest] = splitProps(props, ["fallback", "src", "variant", "unread", "class", "classList", "style"])
|
const [split, rest] = splitProps(props, ["fallback", "src", "variant", "unread", "class", "classList", "style"])
|
||||||
const src = split.src
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
{...rest}
|
{...rest}
|
||||||
@@ -53,9 +52,9 @@ export function ProjectAvatar(props: ProjectAvatarProps) {
|
|||||||
<div
|
<div
|
||||||
data-slot="project-avatar-surface"
|
data-slot="project-avatar-surface"
|
||||||
data-variant={split.variant ?? "gray"}
|
data-variant={split.variant ?? "gray"}
|
||||||
data-has-image={src ? "" : undefined}
|
data-has-image={split.src ? "" : undefined}
|
||||||
>
|
>
|
||||||
<Show when={src} fallback={first(split.fallback)}>
|
<Show when={split.src} fallback={first(split.fallback)}>
|
||||||
{(value) => <img src={value()} draggable={false} data-slot="project-avatar-image" />}
|
{(value) => <img src={value()} draggable={false} data-slot="project-avatar-image" />}
|
||||||
</Show>
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user