Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
258 changes: 250 additions & 8 deletions apps/desktop/src/app/chat/sidebar/project-dialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,22 +19,39 @@ import { type ProjectIdeaTemplate, randomIdeaTemplates } from '@/lib/project-ide
import { cn } from '@/lib/utils'
import { notifyError } from '@/store/notifications'
import {
$projects,
$projectDialog,
addProjectFolder,
closeProjectDialog,
createProject,
generateProjectIdea,
moveProjectFolder,
pickProjectFolder,
removeProjectFolder,
renameProject
} from '@/store/projects'

// Local-only edit state for the edit-folders dialog. Kept outside the atom so
// the user can stage several folder moves + removals before committing them in
// one shot — the per-row optimistic cache update only fires on Save.
interface FolderEdit {
original: string
current: string
// True when the user clicked the trash button on this row. Toggled back off
// by clicking the same button again (the icon swaps to "keep"). A `removed`
// row's `current` is unused — Save sends `projects.remove_folder` for it.
removed: boolean
}

// Single dialog mounted once in the sidebar; it renders create / rename /
// add-folder flows driven by the $projectDialog atom. Folders are chosen via
// the native directory picker (reused from the default-project-dir setting).
// add-folder / edit-folders flows driven by the $projectDialog atom. Folders
// are chosen via the native directory picker (reused from the default-project-
// dir setting).
export function ProjectDialog() {
const { t } = useI18n()
const p = t.sidebar.projects
const state = useStore($projectDialog)
const projects = useStore($projects)
const open = state !== null
const mode = state?.mode ?? 'create'

Expand All @@ -44,6 +61,7 @@ export function ProjectDialog() {
const [templates, setTemplates] = useState<ProjectIdeaTemplate[]>([])
const [generatingIdea, setGeneratingIdea] = useState(false)
const [submitting, setSubmitting] = useState(false)
const [folderEdits, setFolderEdits] = useState<FolderEdit[]>([])
const nameRef = useRef<HTMLInputElement>(null)

useEffect(() => {
Expand All @@ -55,11 +73,21 @@ export function ProjectDialog() {
setGeneratingIdea(false)
setSubmitting(false)

// Seed the edit-folders staging buffer from the project's current
// folders. Done here (not in render) so a re-open resets in-flight edits
// and so a concurrent refresh can't sneak a fresh folder in mid-edit.
if (mode === 'edit-folders') {
const proj = projects.find(p2 => p2.id === state?.projectId)
setFolderEdits((proj?.folders ?? []).map(f => ({ original: f.path, current: f.path, removed: false })))
} else {
setFolderEdits([])
}

if (mode !== 'add-folder') {
window.setTimeout(() => nameRef.current?.select(), 0)
}
}
}, [open, mode, state?.name])
}, [open, mode, state?.name, state?.projectId, projects])

const onOpenChange = (next: boolean) => {
if (!next) {
Expand Down Expand Up @@ -108,6 +136,34 @@ export function ProjectDialog() {
}
}

// Pick a new path for one row in the edit-folders staging buffer. The picker
// returns an absolute path; we replace just that row's `current` so the user
// can stage several moves before committing them all in one submit.
const pickFolderEdit = async (originalPath: string) => {
try {
const dir = await pickProjectFolder()

if (!dir) {
return
}

setFolderEdits(prev =>
prev.map(f => (f.original === originalPath ? { ...f, current: dir } : f))
)
} catch (err) {
notifyError(err, p.editFoldersMoveFailed)
}
}

// Toggle a row's `removed` flag. The button's icon flips between trash (active)
// and "discard"/undo (kept) so a quick second tap restores the row before
// Save commits the removal.
const toggleRemoveFolderEdit = (originalPath: string) => {
setFolderEdits(prev =>
prev.map(f => (f.original === originalPath ? { ...f, removed: !f.removed } : f))
)
}

const submit = async () => {
const trimmed = name.trim()
const projectId = state?.projectId
Expand All @@ -123,7 +179,34 @@ export function ProjectDialog() {
// A project owns sessions by folder (cwd-prefix), so creation requires at
// least one — a folder-less project couldn't hold a session anyway.
if (mode === 'create' && trimmed && folders.length) {
await runSubmit(() => createProject({ folders, idea: idea.trim() || undefined, name: trimmed, use: true }))
await runSubmit(() =>
createProject({ folders, idea: idea.trim() || undefined, name: trimmed, use: true })
)
return
}

// Commit staged folder moves + removals. Order matters: removals run first so
// they don't fight a same-row move for the path slot — a row can be both
// "moved" AND "removed" only if the user explicitly re-toggles (and we
// currently don't expose that combo, so in practice at most one operation
// targets each row). Backend `projects.move_folder` is idempotent for
// same-path, so an empty edit closes immediately.
if (mode === 'edit-folders' && projectId) {
const removals = folderEdits.filter(f => f.removed).map(f => f.original)
const moves = folderEdits.filter(f => !f.removed && f.original !== f.current)
if (!removals.length && !moves.length) {
closeProjectDialog()
return
}

await runSubmit(async () => {
for (const path of removals) {
await removeProjectFolder(projectId, path)
}
for (const move of moves) {
await moveProjectFolder(projectId, move.original, move.current)
}
})
}
}

Expand All @@ -145,17 +228,25 @@ export function ProjectDialog() {
}
}

const title = mode === 'rename' ? p.renameTitle : mode === 'add-folder' ? p.addFolderTitle : p.createTitle
const title =
mode === 'rename'
? p.renameTitle
: mode === 'add-folder'
? p.addFolderTitle
: mode === 'edit-folders'
? p.editFoldersTitle
: p.createTitle

return (
<Dialog onOpenChange={onOpenChange} open={open}>
<DialogContent className="max-w-md" onInteractOutside={event => event.preventDefault()}>
<DialogHeader>
<DialogTitle>{title}</DialogTitle>
{mode === 'create' && <DialogDescription>{p.createDesc}</DialogDescription>}
{mode === 'edit-folders' && <DialogDescription>{p.editFoldersDesc}</DialogDescription>}
</DialogHeader>

{mode !== 'add-folder' && (
{mode !== 'add-folder' && mode !== 'edit-folders' && (
<Input
autoFocus
disabled={submitting}
Expand Down Expand Up @@ -280,7 +371,158 @@ export function ProjectDialog() {
</Button>
)}

{mode !== 'add-folder' && (
{mode === 'edit-folders' && (
<div className="flex flex-col gap-2">
<span className="text-[0.6875rem] font-medium text-(--ui-text-tertiary)">{p.foldersLabel}</span>
{folderEdits.length === 0 ? (
<span className="text-[0.75rem] text-(--ui-text-quaternary)">{p.editFoldersEmpty}</span>
) : (
<ul className="flex flex-col gap-1.5">
{folderEdits.map(edit => {
const changed = !edit.removed && edit.original !== edit.current
// Collision: a staged `current` matches another row's `original`
// (the only case the backend can reject), or duplicates another
// row's staged `current` (would only happen across two rows
// pointing at the same new path). Skipped for removed rows:
// their `current` doesn't ship, so a stale collision flag
// would just confuse the user.
const otherOriginals = new Set(
folderEdits.filter(o => o.original !== edit.original).map(o => o.original)
)
const otherCurrents = folderEdits
.filter(o => o.original !== edit.original && !o.removed)
.map(o => o.current)
const collision =
!edit.removed &&
(otherCurrents.includes(edit.current) ||
(changed && otherOriginals.has(edit.current)))
return (
<li
className={cn(
'flex items-center gap-2 rounded-md bg-(--ui-control-hover-background) px-2 py-1 text-[0.75rem]',
edit.removed && 'opacity-50'
)}
key={edit.original}
>
<Codicon
className="shrink-0 text-(--ui-text-tertiary)"
name={edit.removed ? 'trash' : changed ? 'arrow-right' : 'folder'}
size="0.75rem"
/>
<div className="flex min-w-0 flex-1 flex-col">
<span
className={cn(
'truncate',
edit.removed ? 'text-(--ui-text-tertiary) line-through' : 'text-(--ui-text-quaternary)',
changed && !edit.removed && 'line-through'
)}
title={edit.original}
>
{edit.original}
</span>
{changed && (
<span
className={cn(
'truncate',
collision ? 'text-(--ui-text-danger, #f48771)' : 'text-foreground'
)}
title={edit.current}
>
{edit.current}
</span>
)}
</div>
{changed && (
<span className="shrink-0 text-[0.625rem] uppercase text-(--ui-text-quaternary)">
{p.editFolderChanged}
</span>
)}
{edit.removed && (
<span className="shrink-0 text-[0.625rem] uppercase text-(--ui-text-tertiary)">
{p.editFolderRemoved}
</span>
)}
{!edit.removed && (
<Button
aria-label={p.editFolderPick}
className="size-5 shrink-0 text-(--ui-text-quaternary) hover:text-foreground"
disabled={submitting}
onClick={() => void pickFolderEdit(edit.original)}
size="icon-xs"
type="button"
variant="ghost"
>
<Codicon name="folder-opened" size="0.75rem" />
</Button>
)}
{changed && (
<Button
aria-label={p.editFolderRevert}
className="size-5 shrink-0 text-(--ui-text-quaternary) hover:text-foreground"
disabled={submitting}
onClick={() =>
setFolderEdits(prev =>
prev.map(f =>
f.original === edit.original ? { ...f, current: f.original } : f
)
)
}
size="icon-xs"
type="button"
variant="ghost"
>
<Codicon name="discard" size="0.75rem" />
</Button>
)}
<Button
aria-label={edit.removed ? p.editFolderUnremove : p.editFolderRemove}
className={cn(
'size-5 shrink-0 hover:text-foreground',
edit.removed ? 'text-(--ui-text-danger, #f48771)' : 'text-(--ui-text-quaternary)'
)}
disabled={submitting}
onClick={() => toggleRemoveFolderEdit(edit.original)}
size="icon-xs"
type="button"
variant="ghost"
>
<Codicon name={edit.removed ? 'discard' : 'trash'} size="0.75rem" />
</Button>
</li>
)
})}
</ul>
)}
</div>
)}

{(mode === 'add-folder' || mode === 'edit-folders') && (
<DialogFooter>
<Button
disabled={submitting}
onClick={() => onOpenChange(false)}
type="button"
variant="ghost"
>
{t.common.cancel}
</Button>
<Button
disabled={
submitting ||
// Block Save when no edits AND no removals were staged — submit
// would close immediately, but a stray Enter shouldn't no-op.
(mode === 'edit-folders' &&
folderEdits.every(f => f.original === f.current && !f.removed))
}
onClick={() => void submit()}
type="button"
>
{mode === 'edit-folders' ? p.editFoldersDone : p.addFolder}
</Button>
</DialogFooter>
)}

{mode !== 'add-folder' && mode !== 'edit-folders' && (
<DialogFooter>
<Button disabled={submitting} onClick={() => onOpenChange(false)} type="button" variant="ghost">
{t.common.cancel}
Expand All @@ -297,4 +539,4 @@ export function ProjectDialog() {
</DialogContent>
</Dialog>
)
}
}
17 changes: 17 additions & 0 deletions apps/desktop/src/app/chat/sidebar/projects/project-menu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,9 +18,11 @@ import { PROFILE_SWATCHES } from '@/lib/profile-color'
import { cn } from '@/lib/utils'
import { $panesFlipped, dismissAutoProject } from '@/store/layout'
import {
$projects,
copyPath,
deleteProject,
openProjectAddFolder,
openProjectEditFolders,
openProjectRename,
revealPath,
setActiveProject,
Expand Down Expand Up @@ -87,6 +89,14 @@ export function ProjectMenu({
}) {
const { t } = useI18n()
const p = t.sidebar.projects
const projects = useStore($projects)
// The full ProjectInfo (with the folder list) lives in the `$projects` atom,
// not on the SidebarProjectTree node we render in the kebab menu. Look it up
// here so the Edit folders menu can disable itself for projects with zero
// folders (auto projects are also excluded since the menu is hidden for them
// a few lines down via `!project.isAuto`).
const projectInfo = projects.find(proj => proj.id === project.id)
const folderCount = projectInfo?.folders.length ?? 0
const target = { id: project.id, name: project.label }
const [confirmDeleteOpen, setConfirmDeleteOpen] = useState(false)
const [appearanceOpen, setAppearanceOpen] = useState(false)
Expand Down Expand Up @@ -158,6 +168,13 @@ export function ProjectMenu({
<Codicon name="new-folder" size="0.875rem" />
<span>{p.menuAddFolder}</span>
</DropdownMenuItem>
<DropdownMenuItem
disabled={!project.path || folderCount === 0}
onSelect={() => openProjectEditFolders(target)}
>
<Codicon name="folder-library" size="0.875rem" />
<span>{p.menuEditFolders}</span>
</DropdownMenuItem>
<DropdownMenuItem disabled={isActive} onSelect={() => void setActiveProject(project.id)}>
<Codicon name="target" size="0.875rem" />
<span>{p.menuSetActive}</span>
Expand Down
Loading