Skip to content
Merged
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
44 changes: 29 additions & 15 deletions src/browser/components/ProjectSidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,23 @@ export type { WorkspaceSelection } from "./WorkspaceListItem";
// Draggable project item moved to module scope to avoid remounting on every parent render.
// Defining components inside another component causes a new function identity each render,
// which forces React to unmount/remount the subtree. That led to hover flicker and high CPU.

const PROJECT_ITEM_BASE_CLASS =
"py-2 px-3 flex items-center border-l-transparent bg-sidebar transition-colors duration-150";

function getProjectItemClassName(opts: {
isDragging: boolean;
isOver: boolean;
selected: boolean;
}): string {
return cn(
PROJECT_ITEM_BASE_CLASS,
opts.isDragging ? "cursor-grabbing opacity-35 [&_*]:!cursor-grabbing" : "cursor-grab",
opts.isOver && "bg-accent/[0.08]",
opts.selected && "bg-hover border-l-accent",
"hover:[&_button]:opacity-100 hover:[&_[data-drag-handle]]:opacity-100"
);
}
type DraggableProjectItemProps = React.PropsWithChildren<{
projectPath: string;
onReorder: (draggedPath: string, targetPath: string) => void;
Expand Down Expand Up @@ -87,13 +104,11 @@ const DraggableProjectItemBase: React.FC<DraggableProjectItemProps> = ({
return (
<div
ref={(node) => drag(drop(node))}
className={cn(
"py-2 px-3 flex items-center border-l-transparent transition-all duration-150 bg-sidebar",
isDragging ? "cursor-grabbing opacity-40 [&_*]:!cursor-grabbing" : "cursor-grab",
isOver && "bg-accent/[0.08]",
selected && "bg-hover border-l-accent",
"hover:[&_button]:opacity-100 hover:[&_[data-drag-handle]]:opacity-100"
)}
className={getProjectItemClassName({
isDragging,
isOver,
selected: !!selected,
})}
{...rest}
>
{children}
Expand Down Expand Up @@ -141,18 +156,17 @@ const ProjectDragLayer: React.FC = () => {
if (!isDragging || !currentOffset || !item?.projectPath) return null;

const abbrevPath = PlatformPaths.abbreviate(item.projectPath);
const { dirPath, basename } = PlatformPaths.splitAbbreviated(abbrevPath);
const { basename } = PlatformPaths.splitAbbreviated(abbrevPath);

return (
<div className="pointer-events-none fixed inset-0 z-[9999] cursor-grabbing">
<div style={{ transform: `translate(${currentOffset.x + 10}px, ${currentOffset.y + 10}px)` }}>
<div className="bg-hover/95 text-foreground border-l-accent flex w-fit max-w-72 min-w-44 items-center rounded border-l-[3px] px-3 py-1.5 shadow-[0_6px_24px_rgba(0,0,0,0.4)]">
<span className="text-muted mr-2 text-xs">▶</span>
<div className="min-w-0 flex-1">
<div className="text-muted-dark font-monospace truncate text-sm leading-tight">
<span>{dirPath}</span>
<span className="text-foreground font-medium">{basename}</span>
</div>
<div className={cn(PROJECT_ITEM_BASE_CLASS, "w-fit max-w-64 rounded-sm shadow-lg")}>
<span className="text-secondary mr-2 flex h-5 w-5 shrink-0 items-center justify-center">
<ChevronRight size={12} />
</span>
<div className="flex min-w-0 flex-1 items-center pr-2">
<span className="text-foreground truncate text-sm font-medium">{basename}</span>
</div>
</div>
</div>
Expand Down
8 changes: 4 additions & 4 deletions src/common/utils/projectOrdering.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,11 +69,11 @@ describe("projectOrdering", () => {
expect(result).toEqual(["/a", "/b"]);
});

it("appends new projects to the end", () => {
it("prepends new projects to the front", () => {
const projects = createProjects(["/a", "/b", "/c", "/d"]);
const order = ["/b", "/a"];
const result = normalizeOrder(order, projects);
expect(result).toEqual(["/b", "/a", "/c", "/d"]);
expect(result).toEqual(["/c", "/d", "/b", "/a"]);
});

it("preserves order of existing projects", () => {
Expand Down Expand Up @@ -130,13 +130,13 @@ describe("projectOrdering", () => {
// After projects load, normalization should work normally:
// 1. projectOrder is still ["/a", "/b", "/c"] from localStorage
// 2. Projects are now loaded with an additional project ["/d"]
// 3. Normalization should append the new project
// 3. Normalization should treat the new project as "most recent" and put it first
const projectOrder = ["/a", "/b", "/c"];
const loadedProjects = createProjects(["/a", "/b", "/c", "/d"]);

const result = normalizeOrder(projectOrder, loadedProjects);

expect(result).toEqual(["/a", "/b", "/c", "/d"]);
expect(result).toEqual(["/d", "/a", "/b", "/c"]);
});
});
});
8 changes: 6 additions & 2 deletions src/common/utils/projectOrdering.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,13 +56,17 @@ export function reorderProjects(
/**
* Normalize an order array against the current set of projects.
* - Removes paths that no longer exist
* - Appends new paths to the end (preserving their natural order)
* - Prepends new paths to the front (preserving their natural order)
*
* UX rationale: when a user adds a project, they almost always want to use it next. Putting newly
* added projects at the top makes that action feel "recent" even if the user already has a custom
* project ordering.
*/
export function normalizeOrder(order: string[], projects: Map<string, ProjectConfig>): string[] {
const present = new Set(projects.keys());
const filtered = order.filter((p) => present.has(p));
const missing = Array.from(projects.keys()).filter((p) => !filtered.includes(p));
return [...filtered, ...missing];
return [...missing, ...filtered];
}

/**
Expand Down