Skip to content

Commit 9a0a5bc

Browse files
dbpolitohk9890
authored andcommitted
Desktop: Edit Project (anomalyco#6360)
1 parent 3620777 commit 9a0a5bc

3 files changed

Lines changed: 190 additions & 3 deletions

File tree

Lines changed: 180 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,180 @@
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 { Icon } from "@opencode-ai/ui/icon"
6+
import { createMemo, createSignal, For, Show } from "solid-js"
7+
import { createStore } from "solid-js/store"
8+
import { useGlobalSDK } from "@/context/global-sdk"
9+
import { type LocalProject, getAvatarColors } from "@/context/layout"
10+
import { Avatar } from "@opencode-ai/ui/avatar"
11+
12+
const AVATAR_COLOR_KEYS = ["pink", "mint", "orange", "purple", "cyan", "lime"] as const
13+
14+
function getFilename(input: string) {
15+
const parts = input.split("/")
16+
return parts[parts.length - 1] || input
17+
}
18+
19+
export function DialogEditProject(props: { project: LocalProject }) {
20+
const dialog = useDialog()
21+
const globalSDK = useGlobalSDK()
22+
23+
const folderName = createMemo(() => getFilename(props.project.worktree))
24+
const defaultName = createMemo(() => props.project.name || folderName())
25+
26+
const [store, setStore] = createStore({
27+
name: defaultName(),
28+
color: props.project.icon?.color || "pink",
29+
iconUrl: props.project.icon?.url || "",
30+
saving: false,
31+
})
32+
33+
const [dragOver, setDragOver] = createSignal(false)
34+
35+
function handleFileSelect(file: File) {
36+
if (!file.type.startsWith("image/")) return
37+
const reader = new FileReader()
38+
reader.onload = (e) => setStore("iconUrl", e.target?.result as string)
39+
reader.readAsDataURL(file)
40+
}
41+
42+
function handleDrop(e: DragEvent) {
43+
e.preventDefault()
44+
setDragOver(false)
45+
const file = e.dataTransfer?.files[0]
46+
if (file) handleFileSelect(file)
47+
}
48+
49+
function handleDragOver(e: DragEvent) {
50+
e.preventDefault()
51+
setDragOver(true)
52+
}
53+
54+
function handleDragLeave() {
55+
setDragOver(false)
56+
}
57+
58+
function handleInputChange(e: Event) {
59+
const input = e.target as HTMLInputElement
60+
const file = input.files?.[0]
61+
if (file) handleFileSelect(file)
62+
}
63+
64+
function clearIcon() {
65+
setStore("iconUrl", "")
66+
}
67+
68+
async function handleSubmit(e: SubmitEvent) {
69+
e.preventDefault()
70+
if (!props.project.id) return
71+
72+
setStore("saving", true)
73+
const name = store.name.trim() === folderName() ? "" : store.name.trim()
74+
await globalSDK.client.project.update({
75+
projectID: props.project.id,
76+
name,
77+
icon: { color: store.color, url: store.iconUrl },
78+
})
79+
setStore("saving", false)
80+
dialog.close()
81+
}
82+
83+
return (
84+
<Dialog title="Edit project">
85+
<form onSubmit={handleSubmit} class="flex flex-col gap-6 px-2.5 pb-3">
86+
<div class="flex flex-col gap-4">
87+
<TextField
88+
autofocus
89+
type="text"
90+
label="Name"
91+
placeholder={folderName()}
92+
value={store.name}
93+
onChange={(v) => setStore("name", v)}
94+
/>
95+
96+
<div class="flex flex-col gap-2">
97+
<label class="text-12-medium text-text-weak">Icon</label>
98+
<div class="flex gap-3 items-start">
99+
<div class="relative">
100+
<div
101+
class="size-16 rounded-lg overflow-hidden border border-dashed transition-colors cursor-pointer"
102+
classList={{
103+
"border-text-interactive-base bg-surface-info-base/20": dragOver(),
104+
"border-border-base hover:border-border-strong": !dragOver(),
105+
}}
106+
onDrop={handleDrop}
107+
onDragOver={handleDragOver}
108+
onDragLeave={handleDragLeave}
109+
onClick={() => document.getElementById("icon-upload")?.click()}
110+
>
111+
<Show
112+
when={store.iconUrl}
113+
fallback={
114+
<div class="size-full flex items-center justify-center">
115+
<Avatar
116+
fallback={store.name || defaultName()}
117+
{...getAvatarColors(store.color)}
118+
class="size-full"
119+
/>
120+
</div>
121+
}
122+
>
123+
<img src={store.iconUrl} alt="Project icon" class="size-full object-cover" />
124+
</Show>
125+
</div>
126+
<Show when={store.iconUrl}>
127+
<button
128+
type="button"
129+
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"
130+
onClick={clearIcon}
131+
>
132+
<Icon name="close" class="size-3 text-icon-base" />
133+
</button>
134+
</Show>
135+
</div>
136+
<input id="icon-upload" type="file" accept="image/*" class="hidden" onChange={handleInputChange} />
137+
<div class="flex flex-col gap-1.5 text-12-regular text-text-weak">
138+
<span>Click or drag an image</span>
139+
<span>Recommended: 128x128px</span>
140+
</div>
141+
</div>
142+
</div>
143+
144+
<Show when={!store.iconUrl}>
145+
<div class="flex flex-col gap-2">
146+
<label class="text-12-medium text-text-weak">Color</label>
147+
<div class="flex gap-2">
148+
<For each={AVATAR_COLOR_KEYS}>
149+
{(color) => (
150+
<button
151+
type="button"
152+
class="relative size-8 rounded-md transition-all"
153+
classList={{
154+
"ring-2 ring-offset-2 ring-offset-surface-base ring-text-interactive-base":
155+
store.color === color,
156+
}}
157+
style={{ background: getAvatarColors(color).background }}
158+
onClick={() => setStore("color", color)}
159+
>
160+
<Avatar fallback={store.name || defaultName()} {...getAvatarColors(color)} class="size-full" />
161+
</button>
162+
)}
163+
</For>
164+
</div>
165+
</div>
166+
</Show>
167+
</div>
168+
169+
<div class="flex justify-end gap-2">
170+
<Button type="button" variant="ghost" size="large" onClick={() => dialog.close()}>
171+
Cancel
172+
</Button>
173+
<Button type="submit" variant="primary" size="large" disabled={store.saving}>
174+
{store.saving ? "Saving..." : "Save"}
175+
</Button>
176+
</div>
177+
</form>
178+
</Dialog>
179+
)
180+
}

packages/app/src/context/layout.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,7 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
7070
{
7171
...project,
7272
...(metadata ?? {}),
73+
icon: { url: metadata?.icon?.url, color: metadata?.icon?.color },
7374
},
7475
]
7576
}

packages/app/src/pages/layout.tsx

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@ import { Header } from "@/components/header"
4949
import { useDialog } from "@opencode-ai/ui/context/dialog"
5050
import { useTheme, type ColorScheme } from "@opencode-ai/ui/theme"
5151
import { DialogSelectProvider } from "@/components/dialog-select-provider"
52+
import { DialogEditProject } from "@/components/dialog-edit-project"
5253
import { useCommand, type CommandOption } from "@/context/command"
5354
import { ConstrainDragXAxis } from "@/utils/solid-dnd"
5455

@@ -522,7 +523,7 @@ export default function Layout(props: ParentProps) {
522523
const notification = useNotification()
523524
const notifications = createMemo(() => notification.project.unseen(props.project.worktree))
524525
const hasError = createMemo(() => notifications().some((n) => n.type === "error"))
525-
const name = createMemo(() => getFilename(props.project.worktree))
526+
const name = createMemo(() => props.project.name || getFilename(props.project.worktree))
526527
const mask = "radial-gradient(circle 5px at calc(100% - 2px) 2px, transparent 5px, black 5.5px)"
527528
const opencode = "4b0ea68d7af9a6031a7ffda7ad66e0cb83315750"
528529

@@ -558,7 +559,7 @@ export default function Layout(props: ParentProps) {
558559
}
559560

560561
const ProjectVisual = (props: { project: LocalProject; class?: string }): JSX.Element => {
561-
const name = createMemo(() => getFilename(props.project.worktree))
562+
const name = createMemo(() => props.project.name || getFilename(props.project.worktree))
562563
const current = createMemo(() => base64Decode(params.dir ?? ""))
563564
return (
564565
<Switch>
@@ -701,7 +702,7 @@ export default function Layout(props: ParentProps) {
701702
const sortable = createSortable(props.project.worktree)
702703
const showExpanded = createMemo(() => props.mobile || layout.sidebar.opened())
703704
const slug = createMemo(() => base64Encode(props.project.worktree))
704-
const name = createMemo(() => getFilename(props.project.worktree))
705+
const name = createMemo(() => props.project.name || getFilename(props.project.worktree))
705706
const [store, setProjectStore] = globalSync.child(props.project.worktree)
706707
const sessions = createMemo(() => store.session.toSorted(sortSessions))
707708
const rootSessions = createMemo(() => sessions().filter((s) => !s.parentID))
@@ -747,6 +748,11 @@ export default function Layout(props: ParentProps) {
747748
<DropdownMenu.Trigger as={IconButton} icon="dot-grid" variant="ghost" />
748749
<DropdownMenu.Portal>
749750
<DropdownMenu.Content>
751+
<DropdownMenu.Item
752+
onSelect={() => dialog.show(() => <DialogEditProject project={props.project} />)}
753+
>
754+
<DropdownMenu.ItemLabel>Edit project</DropdownMenu.ItemLabel>
755+
</DropdownMenu.Item>
750756
<DropdownMenu.Item onSelect={() => closeProject(props.project.worktree)}>
751757
<DropdownMenu.ItemLabel>Close project</DropdownMenu.ItemLabel>
752758
</DropdownMenu.Item>

0 commit comments

Comments
 (0)