Enhance PathBar component with editing functionality

- Introduced editing mode in the PathBar component, allowing users to modify paths directly.
- Added state management for editing, including handling input changes and keyboard events for submission and cancellation.
- Updated the rendering logic to accommodate editing input, improving user interaction.
- Refactored device icon retrieval to streamline the process and ensure correct icon display based on device state.
- Adjusted width calculations for different states, enhancing the visual responsiveness of the PathBar.
This commit is contained in:
Jamie Pine
2025-12-23 00:34:53 -08:00
parent ce1f52bde9
commit e0989a01a5
2 changed files with 138 additions and 34 deletions

View File

@@ -10,7 +10,7 @@ import {
RadioButtonIcon,
} from "@phosphor-icons/react";
import type { SdPath, LibraryDeviceInfo } from "@sd/ts-client";
import { getDeviceIconBySlug, useLibraryMutation } from "@sd/ts-client";
import { getDeviceIcon, useLibraryMutation } from "@sd/ts-client";
import { sdPathToUri } from "../utils";
import LaptopIcon from "@sd/assets/icons/Laptop.png";
import { useNormalizedQuery } from "@sd/ts-client";
@@ -250,6 +250,9 @@ function IndexIndicator({ path }: { path: SdPath }) {
export function PathBar({ path, devices, onNavigate }: PathBarProps) {
const [isExpanded, setIsExpanded] = useState(false);
const [isShiftHeld, setIsShiftHeld] = useState(false);
const [isEditing, setIsEditing] = useState(false);
const [editValue, setEditValue] = useState("");
const [editingAsUri, setEditingAsUri] = useState(false);
const { navigateToView } = useExplorer();
const uri = sdPathToUri(path);
const currentDir = getCurrentDirectoryName(path);
@@ -264,7 +267,7 @@ export function PathBar({ path, devices, onNavigate }: PathBarProps) {
(d) => d.slug === deviceSlug,
);
return {
icon: getDeviceIconBySlug(deviceSlug, devices),
icon: device ? getDeviceIcon(device) : LaptopIcon,
device,
};
}
@@ -278,6 +281,68 @@ export function PathBar({ path, devices, onNavigate }: PathBarProps) {
}
};
const enterEditMode = (initialValue: string, asUri: boolean) => {
setIsEditing(true);
setEditValue(initialValue);
setEditingAsUri(asUri);
};
const exitEditMode = () => {
setIsEditing(false);
setEditValue("");
setEditingAsUri(false);
};
const handleContainerClick = (e: React.MouseEvent) => {
// Only enter edit mode if clicking the container itself, not buttons/segments
if (e.target === e.currentTarget || (e.target as HTMLElement).tagName === "INPUT") {
const isUriMode = showUri;
const valueToEdit = isUriMode ? uri : ("Physical" in path ? path.Physical.path : uri);
enterEditMode(valueToEdit, isUriMode);
}
};
const handleEditKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === "Enter") {
e.preventDefault();
submitEdit();
} else if (e.key === "Escape") {
e.preventDefault();
exitEditMode();
}
};
const submitEdit = () => {
const trimmed = editValue.trim();
if (!trimmed) {
exitEditMode();
return;
}
try {
if (editingAsUri) {
// Try to parse as SdPath JSON
const parsed = JSON.parse(trimmed) as SdPath;
onNavigate(parsed);
} else {
// Parse as file path string
if ("Physical" in path) {
const newPath: SdPath = {
Physical: {
device_slug: path.Physical.device_slug,
path: trimmed.startsWith("/") ? trimmed : `/${trimmed}`,
},
};
onNavigate(newPath);
}
}
} catch (error) {
console.error("Failed to parse path:", error);
}
exitEditMode();
};
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === "Shift") setIsShiftHeld(true);
@@ -297,7 +362,7 @@ export function PathBar({ path, devices, onNavigate }: PathBarProps) {
const showUri = isExpanded && isShiftHeld;
// Calculate widths for three states
// Calculate widths for different states
const collapsedWidth = currentDir.length * 8.5 + 70;
const breadcrumbsWidth = Math.min(
segments.reduce((sum, seg) => sum + seg.name.length * 6.5, 0) +
@@ -306,29 +371,37 @@ export function PathBar({ path, devices, onNavigate }: PathBarProps) {
600,
);
const uriWidth = Math.min(uri.length * 7 + 70, 600);
const editWidth = Math.max(200, Math.min(editValue.length * 7 + 70, 600));
const currentWidth = !isExpanded
? collapsedWidth
: showUri
? uriWidth
: breadcrumbsWidth;
const currentWidth = isEditing
? editWidth
: !isExpanded
? collapsedWidth
: showUri
? uriWidth
: breadcrumbsWidth;
return (
<div className="flex items-center gap-2">
<motion.div
animate={{ width: currentWidth }}
transition={{ duration: 0.2, ease: [0.25, 1, 0.5, 1] }}
onMouseEnter={() => setIsExpanded(true)}
onMouseLeave={() => setIsExpanded(false)}
onMouseEnter={() => !isEditing && setIsExpanded(true)}
onMouseLeave={() => !isEditing && setIsExpanded(false)}
onClick={handleContainerClick}
className={clsx(
"flex items-center gap-1.5 h-8 px-3 rounded-full",
"backdrop-blur-xl border border-sidebar-line/30",
"bg-sidebar-box/20 transition-colors",
"focus-within:bg-sidebar-box/30 focus-within:border-sidebar-line/40",
!isEditing && "cursor-text",
)}
>
<button
onClick={handleDeviceClick}
onClick={(e) => {
e.stopPropagation();
handleDeviceClick();
}}
disabled={!deviceInfo.device}
title={
deviceInfo.device
@@ -349,7 +422,24 @@ export function PathBar({ path, devices, onNavigate }: PathBarProps) {
/>
</button>
{showUri ? (
{isEditing ? (
<input
type="text"
value={editValue}
onChange={(e) => setEditValue(e.target.value)}
onKeyDown={handleEditKeyDown}
onBlur={exitEditMode}
autoFocus
className={clsx(
"bg-transparent border-0 outline-none ring-0 flex-1 min-w-0",
"text-xs font-medium text-sidebar-ink",
"placeholder:text-sidebar-inkFaint",
"focus:ring-0 focus:outline-none",
editingAsUri && "font-mono",
)}
placeholder={editingAsUri ? "Enter SdPath JSON..." : "Enter path..."}
/>
) : showUri ? (
<input
type="text"
value={uri}
@@ -373,9 +463,10 @@ export function PathBar({ path, devices, onNavigate }: PathBarProps) {
className="flex items-center gap-1 flex-shrink-0"
>
<button
onClick={() =>
!isLast && onNavigate(segment.path)
}
onClick={(e) => {
e.stopPropagation();
!isLast && onNavigate(segment.path);
}}
disabled={isLast}
className={clsx(
"text-xs font-medium transition-colors whitespace-nowrap",
@@ -386,7 +477,18 @@ export function PathBar({ path, devices, onNavigate }: PathBarProps) {
>
{segment.name}
</button>
{!isLast && <CaretRight size={12} />}
{!isLast && (
<button
onClick={(e) => {
e.stopPropagation();
const valueToEdit = "Physical" in path ? path.Physical.path : uri;
enterEditMode(valueToEdit, false);
}}
className="opacity-50 hover:opacity-100 transition-opacity cursor-text"
>
<CaretRight size={12} />
</button>
)}
</div>
);
})}

View File

@@ -141,7 +141,7 @@ function SyncButton() {
/>
}
side="top"
align="end"
align="start"
sideOffset={8}
className="w-[380px] max-h-[520px] z-50 !p-0 !bg-app !rounded-xl"
>
@@ -247,7 +247,7 @@ function JobsButton({
/>
}
side="top"
align="end"
align="start"
sideOffset={8}
className="w-[360px] max-h-[480px] z-50 !p-0 !bg-app !rounded-xl"
>
@@ -421,22 +421,24 @@ export function SpacesSidebar({ isPreviewActive = false }: SpacesSidebarProps) {
</div>
{/* Sync Monitor, Job Manager, Customize & Settings (pinned to bottom) */}
<div className="flex items-center justify-end gap-2">
<SyncButton />
<JobsButton
activeJobCount={activeJobCount}
hasRunningJobs={hasRunningJobs}
jobs={jobs}
pause={pause}
resume={resume}
cancel={cancel}
navigate={navigate}
/>
<TopBarButton
icon={Palette}
title="Customize"
onClick={() => setCustomizePanelOpen(true)}
/>
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<SyncButton />
<JobsButton
activeJobCount={activeJobCount}
hasRunningJobs={hasRunningJobs}
jobs={jobs}
pause={pause}
resume={resume}
cancel={cancel}
navigate={navigate}
/>
<TopBarButton
icon={Palette}
title="Customize"
onClick={() => setCustomizePanelOpen(true)}
/>
</div>
<TopBarButton
icon={GearSix}
title="Settings"