browse for the folder instead of typing it

the project form asked for a path and gave a text field, while fofos-admin — built
hours earlier — had a directory picker for exactly the same question. that picker
is now shared from the officerdev barrel, so this uses it.

browse OR type: the picker fills the field and the FIELD is what saves, so the path
stays visible and editable before it is stored. a folder outside home is still
reachable by typing, and the server refuses it with a reason rather than the picker
silently making it unreachable.

picking a folder offers its last two segments as the name when the name is empty —
`thing/todo` — because that is what somebody would have typed. left editable.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-29 07:09:49 +01:00
co-authored by Claude Opus 5
parent 6e54f223e6
commit 24354049f4
+37 -6
View File
@@ -1,6 +1,7 @@
import { useEffect, useState } from 'react';
import { FolderPlus, FolderTree, Loader2, Trash2 } from 'lucide-react';
import { toast } from 'sonner';
import { DirectoryPicker } from 'officerdev';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { useProjects, useSelectedProject } from './useKanban';
@@ -27,6 +28,7 @@ export const ProjectsPanel = () => {
const [name, setName] = useState('');
const [path, setPath] = useState('');
const [confirming, setConfirming] = useState<number | null>(null);
const [picking, setPicking] = useState(false);
/*
Land on something rather than on nothing.
@@ -72,14 +74,43 @@ export const ProjectsPanel = () => {
{adding && (
<form onSubmit={submit} className="flex flex-col gap-2 border-b border-border p-3">
<Input autoFocus value={name} onChange={(e) => setName(e.target.value)} placeholder="Name" className="text-sm" />
<Input
value={path}
onChange={(e) => setPath(e.target.value)}
placeholder="~/projects/thing/todo"
className="font-mono text-xs"
spellCheck={false}
autoFocus
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="Name"
className="text-sm"
/>
{/*
Browse OR type. The picker fills the field and the field is what saves, so the path stays
visible and editable before it is stored — and a folder outside home, which the picker
cannot reach, can still be typed and will be refused by the server with a reason.
*/}
<div className="flex gap-2">
<Input
value={path}
onChange={(e) => setPath(e.target.value)}
placeholder="~/projects/thing/todo"
className="min-w-0 flex-1 font-mono text-xs"
spellCheck={false}
/>
<Button type="button" variant="outline" size="sm" onClick={() => setPicking((open) => !open)}>
{picking ? 'Close' : 'Browse'}
</Button>
</div>
{picking && (
<DirectoryPicker
onCancel={() => setPicking(false)}
onPick={(absolutePath) => {
setPath(absolutePath);
setPicking(false);
// A folder is usually named after itself — offer that as the name rather than an empty
// field, and leave it editable.
if (!name.trim()) setName(absolutePath.split('/').filter(Boolean).slice(-2).join('/'));
}}
/>
)}
{/* Said before they press it, not after it fails: the folder holding the theme directories is
not the repository root, and the two are one level apart. */}
<p className="text-[11px] text-muted-foreground/70">