chat: Browse… modal directory picker for the pwd selector
A "Browse…" entry in the pwd popover opens a simplified file-browser modal (breadcrumb nav, subfolder list, New folder) that returns an absolute path to use as the /chat working directory. Built on useFilesAPI within the home root; dirs outside home stay reachable via the auto-discovered list and free-text field. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,136 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import { Folder, ChevronRight, Loader2, FolderPlus, Check } from 'lucide-react';
|
||||||
|
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
|
||||||
|
import { useFilesAPI, type DirEntry } from '../../hooks/useFilesAPI';
|
||||||
|
|
||||||
|
type DirPickerModalProps = {
|
||||||
|
open: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
onSelect: (absPath: string) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
// A simplified file-browser modal for picking a working directory (returns an absolute path).
|
||||||
|
// Navigates within the home root; dirs elsewhere are reachable via the selector's free-text field.
|
||||||
|
export const DirPickerModal = ({ open, onClose, onSelect }: DirPickerModalProps) => {
|
||||||
|
const api = useFilesAPI('home');
|
||||||
|
const [path, setPath] = useState('/'); // root-relative, always starts with '/'
|
||||||
|
const [creating, setCreating] = useState(false);
|
||||||
|
const [newName, setNewName] = useState('');
|
||||||
|
|
||||||
|
const { data, isLoading, refetch } = useQuery({
|
||||||
|
queryKey: ['dir-picker', path],
|
||||||
|
queryFn: () => api.listDir(path),
|
||||||
|
enabled: open,
|
||||||
|
});
|
||||||
|
|
||||||
|
const rootDir = data?.rootDir ?? '';
|
||||||
|
const dirs = ((data?.entries ?? []) as DirEntry[])
|
||||||
|
.filter((e) => e.type === 'directory')
|
||||||
|
.sort((a, b) => a.name.localeCompare(b.name));
|
||||||
|
const absCurrent = path === '/' ? rootDir : `${rootDir}${path}`;
|
||||||
|
|
||||||
|
const segments = path.split('/').filter(Boolean);
|
||||||
|
const goto = (idx: number) => setPath(idx < 0 ? '/' : `/${segments.slice(0, idx + 1).join('/')}`);
|
||||||
|
const enter = (name: string) => setPath(path === '/' ? `/${name}` : `${path}/${name}`);
|
||||||
|
|
||||||
|
const createFolder = async () => {
|
||||||
|
const n = newName.trim();
|
||||||
|
if (!n) return;
|
||||||
|
await api.createDir(path === '/' ? `/${n}` : `${path}/${n}`);
|
||||||
|
setNewName('');
|
||||||
|
setCreating(false);
|
||||||
|
refetch();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={(o) => !o && onClose()}>
|
||||||
|
<DialogContent className="flex h-[70vh] max-w-2xl flex-col gap-0 p-0">
|
||||||
|
<DialogHeader className="border-b border-duck-dark/10 dark:border-foreground/10 px-4 py-3">
|
||||||
|
<DialogTitle className="text-sm">Choose a working directory</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
{/* Breadcrumb */}
|
||||||
|
<div className="flex flex-wrap items-center gap-1 border-b border-duck-dark/10 dark:border-foreground/10 px-4 py-2 text-xs text-duck-dark/60 dark:text-foreground/60">
|
||||||
|
<button onClick={() => goto(-1)} className="hover:text-duck-teal cursor-pointer">
|
||||||
|
~
|
||||||
|
</button>
|
||||||
|
{segments.map((s, i) => (
|
||||||
|
<span key={i} className="flex items-center gap-1">
|
||||||
|
<ChevronRight className="h-3 w-3 opacity-40" />
|
||||||
|
<button onClick={() => goto(i)} className="hover:text-duck-teal cursor-pointer">
|
||||||
|
{s}
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Directory list */}
|
||||||
|
<div className="min-h-0 flex-1 overflow-y-auto p-2">
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="flex h-full items-center justify-center opacity-50">
|
||||||
|
<Loader2 className="h-5 w-5 animate-spin" />
|
||||||
|
</div>
|
||||||
|
) : dirs.length === 0 ? (
|
||||||
|
<div className="flex h-full items-center justify-center text-sm opacity-40">No subfolders here</div>
|
||||||
|
) : (
|
||||||
|
dirs.map((d) => (
|
||||||
|
<button
|
||||||
|
key={d.name}
|
||||||
|
onClick={() => enter(d.name)}
|
||||||
|
className="flex w-full items-center gap-2 rounded px-3 py-2 text-left text-sm hover:bg-accent cursor-pointer"
|
||||||
|
>
|
||||||
|
<Folder className="h-4 w-4 shrink-0 text-duck-teal/70" />
|
||||||
|
<span className="truncate">{d.name}</span>
|
||||||
|
</button>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Footer */}
|
||||||
|
<div className="flex flex-col gap-2 border-t border-duck-dark/10 dark:border-foreground/10 px-4 py-3">
|
||||||
|
{creating ? (
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<input
|
||||||
|
autoFocus
|
||||||
|
value={newName}
|
||||||
|
onChange={(ev) => setNewName(ev.target.value)}
|
||||||
|
onKeyDown={(ev) => {
|
||||||
|
if (ev.key === 'Enter') createFolder();
|
||||||
|
if (ev.key === 'Escape') setCreating(false);
|
||||||
|
}}
|
||||||
|
placeholder="New folder name"
|
||||||
|
className="min-w-0 flex-1 rounded border border-duck-dark/15 dark:border-foreground/15 bg-transparent px-2 py-1 text-xs outline-none"
|
||||||
|
/>
|
||||||
|
<button onClick={createFolder} className="rounded px-2 py-1 text-xs text-duck-teal hover:bg-duck-teal/10 cursor-pointer">
|
||||||
|
Create
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<button onClick={() => setCreating(true)} className="flex items-center gap-1.5 self-start text-xs opacity-60 hover:opacity-100 cursor-pointer">
|
||||||
|
<FolderPlus className="h-3.5 w-3.5" /> New folder
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="min-w-0 flex-1 truncate text-xs opacity-60" title={absCurrent}>
|
||||||
|
{absCurrent}
|
||||||
|
</span>
|
||||||
|
<button onClick={onClose} className="rounded-md px-3 py-1.5 text-sm opacity-60 hover:opacity-100 cursor-pointer">
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
onSelect(absCurrent);
|
||||||
|
onClose();
|
||||||
|
}}
|
||||||
|
disabled={!absCurrent}
|
||||||
|
className="flex items-center gap-1.5 rounded-md bg-duck-teal px-3 py-1.5 text-sm font-medium text-duck-yellow hover:bg-duck-teal/90 disabled:opacity-50 cursor-pointer"
|
||||||
|
>
|
||||||
|
<Check className="h-3.5 w-3.5" /> Select this folder
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { FolderOpen, ChevronDown, Check, CornerDownLeft } from 'lucide-react';
|
import { FolderOpen, ChevronDown, Check, CornerDownLeft, FolderSearch } from 'lucide-react';
|
||||||
import { useChatPwds } from 'state/useClaudeSessions';
|
import { useChatPwds } from 'state/useClaudeSessions';
|
||||||
|
import { DirPickerModal } from './DirPickerModal';
|
||||||
|
|
||||||
type PwdSelectorProps = {
|
type PwdSelectorProps = {
|
||||||
value: string | null; // null = the default claude_sessions dir
|
value: string | null; // null = the default claude_sessions dir
|
||||||
@@ -14,6 +15,7 @@ const basename = (cwd: string) => cwd.split('/').filter(Boolean).pop() ?? cwd;
|
|||||||
export const PwdSelector = ({ value, onChange }: PwdSelectorProps) => {
|
export const PwdSelector = ({ value, onChange }: PwdSelectorProps) => {
|
||||||
const { pwds, defaultCwd } = useChatPwds();
|
const { pwds, defaultCwd } = useChatPwds();
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
|
const [browse, setBrowse] = useState(false);
|
||||||
const [custom, setCustom] = useState('');
|
const [custom, setCustom] = useState('');
|
||||||
|
|
||||||
const isDefaultActive = value === null || value === defaultCwd;
|
const isDefaultActive = value === null || value === defaultCwd;
|
||||||
@@ -62,6 +64,15 @@ export const PwdSelector = ({ value, onChange }: PwdSelectorProps) => {
|
|||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setOpen(false);
|
||||||
|
setBrowse(true);
|
||||||
|
}}
|
||||||
|
className="flex w-full items-center gap-2 border-t border-duck-dark/10 dark:border-foreground/10 px-3 py-2 text-left text-xs text-duck-dark/70 dark:text-foreground/70 hover:bg-duck-dark/5 dark:hover:bg-foreground/5 cursor-pointer"
|
||||||
|
>
|
||||||
|
<FolderSearch className="h-3.5 w-3.5 shrink-0 text-duck-teal/70" /> Browse…
|
||||||
|
</button>
|
||||||
<div className="flex items-center gap-1 border-t border-duck-dark/10 dark:border-foreground/10 p-2">
|
<div className="flex items-center gap-1 border-t border-duck-dark/10 dark:border-foreground/10 p-2">
|
||||||
<input
|
<input
|
||||||
value={custom}
|
value={custom}
|
||||||
@@ -82,6 +93,8 @@ export const PwdSelector = ({ value, onChange }: PwdSelectorProps) => {
|
|||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<DirPickerModal open={browse} onClose={() => setBrowse(false)} onSelect={(p) => pick(p)} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user