summaryrefslogtreecommitdiffhomepage
path: root/packages/app/src
diff options
context:
space:
mode:
authorDaniel Polito <[email protected]>2025-12-29 11:54:49 -0300
committerGitHub <[email protected]>2025-12-29 08:54:49 -0600
commit0c6da69f39490f96e03759dc0b638718662abe70 (patch)
tree047b1ce79507cf6b2fa3b8a42eda0d06328f11b5 /packages/app/src
parentc4930eb6b219a2011cddc70a9b76f41809418114 (diff)
downloadopencode-0c6da69f39490f96e03759dc0b638718662abe70.tar.gz
opencode-0c6da69f39490f96e03759dc0b638718662abe70.zip
Desktop: Edit Project (#6360)
Diffstat (limited to 'packages/app/src')
-rw-r--r--packages/app/src/components/dialog-edit-project.tsx180
-rw-r--r--packages/app/src/context/layout.tsx1
-rw-r--r--packages/app/src/pages/layout.tsx12
3 files changed, 190 insertions, 3 deletions
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 000000000..27ce3903c
--- /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 (
+ <Dialog title="Edit project">
+ <form onSubmit={handleSubmit} class="flex flex-col gap-6 px-2.5 pb-3">
+ <div class="flex flex-col gap-4">
+ <TextField
+ autofocus
+ type="text"
+ label="Name"
+ placeholder={folderName()}
+ value={store.name}
+ onChange={(v) => setStore("name", v)}
+ />
+
+ <div class="flex flex-col gap-2">
+ <label class="text-12-medium text-text-weak">Icon</label>
+ <div class="flex gap-3 items-start">
+ <div class="relative">
+ <div
+ class="size-16 rounded-lg overflow-hidden border border-dashed transition-colors cursor-pointer"
+ classList={{
+ "border-text-interactive-base bg-surface-info-base/20": dragOver(),
+ "border-border-base hover:border-border-strong": !dragOver(),
+ }}
+ onDrop={handleDrop}
+ onDragOver={handleDragOver}
+ onDragLeave={handleDragLeave}
+ onClick={() => document.getElementById("icon-upload")?.click()}
+ >
+ <Show
+ when={store.iconUrl}
+ fallback={
+ <div class="size-full flex items-center justify-center">
+ <Avatar
+ fallback={store.name || defaultName()}
+ {...getAvatarColors(store.color)}
+ class="size-full"
+ />
+ </div>
+ }
+ >
+ <img src={store.iconUrl} alt="Project icon" class="size-full object-cover" />
+ </Show>
+ </div>
+ <Show when={store.iconUrl}>
+ <button
+ type="button"
+ class="absolute -top-1.5 -right-1.5 size-5 rounded-full bg-surface-raised-base border border-border-base flex items-center justify-center hover:bg-surface-raised-base-hover"
+ onClick={clearIcon}
+ >
+ <Icon name="close" class="size-3 text-icon-base" />
+ </button>
+ </Show>
+ </div>
+ <input id="icon-upload" type="file" accept="image/*" class="hidden" onChange={handleInputChange} />
+ <div class="flex flex-col gap-1.5 text-12-regular text-text-weak">
+ <span>Click or drag an image</span>
+ <span>Recommended: 128x128px</span>
+ </div>
+ </div>
+ </div>
+
+ <Show when={!store.iconUrl}>
+ <div class="flex flex-col gap-2">
+ <label class="text-12-medium text-text-weak">Color</label>
+ <div class="flex gap-2">
+ <For each={AVATAR_COLOR_KEYS}>
+ {(color) => (
+ <button
+ type="button"
+ class="relative size-8 rounded-md transition-all"
+ classList={{
+ "ring-2 ring-offset-2 ring-offset-surface-base ring-text-interactive-base":
+ store.color === color,
+ }}
+ style={{ background: getAvatarColors(color).background }}
+ onClick={() => setStore("color", color)}
+ >
+ <Avatar fallback={store.name || defaultName()} {...getAvatarColors(color)} class="size-full" />
+ </button>
+ )}
+ </For>
+ </div>
+ </div>
+ </Show>
+ </div>
+
+ <div class="flex justify-end gap-2">
+ <Button type="button" variant="ghost" size="large" onClick={() => dialog.close()}>
+ Cancel
+ </Button>
+ <Button type="submit" variant="primary" size="large" disabled={store.saving}>
+ {store.saving ? "Saving..." : "Save"}
+ </Button>
+ </div>
+ </form>
+ </Dialog>
+ )
+}
diff --git a/packages/app/src/context/layout.tsx b/packages/app/src/context/layout.tsx
index c6ba5fef5..4ccab98e3 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 2bc0c3131..0b9178948 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 (
<Switch>
@@ -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) {
<DropdownMenu.Trigger as={IconButton} icon="dot-grid" variant="ghost" />
<DropdownMenu.Portal>
<DropdownMenu.Content>
+ <DropdownMenu.Item
+ onSelect={() => dialog.show(() => <DialogEditProject project={props.project} />)}
+ >
+ <DropdownMenu.ItemLabel>Edit project</DropdownMenu.ItemLabel>
+ </DropdownMenu.Item>
<DropdownMenu.Item onSelect={() => closeProject(props.project.worktree)}>
<DropdownMenu.ItemLabel>Close project</DropdownMenu.ItemLabel>
</DropdownMenu.Item>