diff --git a/packages/app/src/components/dialog-edit-project.tsx b/packages/app/src/components/dialog-edit-project.tsx new file mode 100644 index 00000000000..27ce3903cdc --- /dev/null +++ b/packages/app/src/components/dialog-edit-project.tsx @@ -0,0 +1,180 @@ +import { Button } from "@opencode-ai/ui/button" +import { useDialog } from "@opencode-ai/ui/context/dialog" +import { Dialog } from "@opencode-ai/ui/dialog" +import { TextField } from "@opencode-ai/ui/text-field" +import { Icon } from "@opencode-ai/ui/icon" +import { createMemo, createSignal, For, Show } from "solid-js" +import { createStore } from "solid-js/store" +import { useGlobalSDK } from "@/context/global-sdk" +import { type LocalProject, getAvatarColors } from "@/context/layout" +import { Avatar } from "@opencode-ai/ui/avatar" + +const AVATAR_COLOR_KEYS = ["pink", "mint", "orange", "purple", "cyan", "lime"] as const + +function getFilename(input: string) { + const parts = input.split("/") + return parts[parts.length - 1] || input +} + +export function DialogEditProject(props: { project: LocalProject }) { + const dialog = useDialog() + const globalSDK = useGlobalSDK() + + 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 || "pink", + iconUrl: props.project.icon?.url || "", + saving: false, + }) + + const [dragOver, setDragOver] = createSignal(false) + + function handleFileSelect(file: File) { + if (!file.type.startsWith("image/")) return + const reader = new FileReader() + reader.onload = (e) => setStore("iconUrl", e.target?.result as string) + reader.readAsDataURL(file) + } + + function handleDrop(e: DragEvent) { + e.preventDefault() + setDragOver(false) + const file = e.dataTransfer?.files[0] + if (file) handleFileSelect(file) + } + + function handleDragOver(e: DragEvent) { + e.preventDefault() + setDragOver(true) + } + + function handleDragLeave() { + setDragOver(false) + } + + function handleInputChange(e: Event) { + const input = e.target as HTMLInputElement + const file = input.files?.[0] + if (file) handleFileSelect(file) + } + + function clearIcon() { + setStore("iconUrl", "") + } + + async function handleSubmit(e: SubmitEvent) { + e.preventDefault() + if (!props.project.id) return + + setStore("saving", true) + const name = store.name.trim() === folderName() ? "" : store.name.trim() + await globalSDK.client.project.update({ + projectID: props.project.id, + name, + icon: { color: store.color, url: store.iconUrl }, + }) + setStore("saving", false) + dialog.close() + } + + return ( + + + + setStore("name", v)} + /> + + + Icon + + + document.getElementById("icon-upload")?.click()} + > + + + + } + > + + + + + + + + + + + + Click or drag an image + Recommended: 128x128px + + + + + + + Color + + + {(color) => ( + setStore("color", color)} + > + + + )} + + + + + + + + dialog.close()}> + Cancel + + + {store.saving ? "Saving..." : "Save"} + + + + + ) +} diff --git a/packages/app/src/context/layout.tsx b/packages/app/src/context/layout.tsx index c6ba5fef5a1..4ccab98e3ff 100644 --- a/packages/app/src/context/layout.tsx +++ b/packages/app/src/context/layout.tsx @@ -70,6 +70,7 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext( { ...project, ...(metadata ?? {}), + icon: { url: metadata?.icon?.url, color: metadata?.icon?.color }, }, ] } diff --git a/packages/app/src/pages/layout.tsx b/packages/app/src/pages/layout.tsx index 2bc0c313149..0b917894809 100644 --- a/packages/app/src/pages/layout.tsx +++ b/packages/app/src/pages/layout.tsx @@ -49,6 +49,7 @@ import { Header } from "@/components/header" import { useDialog } from "@opencode-ai/ui/context/dialog" import { useTheme, type ColorScheme } from "@opencode-ai/ui/theme" import { DialogSelectProvider } from "@/components/dialog-select-provider" +import { DialogEditProject } from "@/components/dialog-edit-project" import { useCommand, type CommandOption } from "@/context/command" import { ConstrainDragXAxis } from "@/utils/solid-dnd" @@ -522,7 +523,7 @@ export default function Layout(props: ParentProps) { const notification = useNotification() const notifications = createMemo(() => notification.project.unseen(props.project.worktree)) const hasError = createMemo(() => notifications().some((n) => n.type === "error")) - const name = createMemo(() => getFilename(props.project.worktree)) + const name = createMemo(() => props.project.name || getFilename(props.project.worktree)) const mask = "radial-gradient(circle 5px at calc(100% - 2px) 2px, transparent 5px, black 5.5px)" const opencode = "4b0ea68d7af9a6031a7ffda7ad66e0cb83315750" @@ -558,7 +559,7 @@ export default function Layout(props: ParentProps) { } const ProjectVisual = (props: { project: LocalProject; class?: string }): JSX.Element => { - const name = createMemo(() => getFilename(props.project.worktree)) + const name = createMemo(() => props.project.name || getFilename(props.project.worktree)) const current = createMemo(() => base64Decode(params.dir ?? "")) return ( @@ -701,7 +702,7 @@ export default function Layout(props: ParentProps) { const sortable = createSortable(props.project.worktree) const showExpanded = createMemo(() => props.mobile || layout.sidebar.opened()) const slug = createMemo(() => base64Encode(props.project.worktree)) - const name = createMemo(() => getFilename(props.project.worktree)) + const name = createMemo(() => props.project.name || getFilename(props.project.worktree)) const [store, setProjectStore] = globalSync.child(props.project.worktree) const sessions = createMemo(() => store.session.toSorted(sortSessions)) const rootSessions = createMemo(() => sessions().filter((s) => !s.parentID)) @@ -747,6 +748,11 @@ export default function Layout(props: ParentProps) { + dialog.show(() => )} + > + Edit project + closeProject(props.project.worktree)}> Close project