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
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ import { useDashboardSidebarShortcuts } from "./hooks/useDashboardSidebarShortcu
import { DashboardSidebarHoverProvider } from "./providers/DashboardSidebarHoverProvider";
import { DashboardSidebarPortsProvider } from "./providers/DashboardSidebarPortsProvider";
import { DashboardSidebarSelectionProvider } from "./providers/DashboardSidebarSelectionProvider";
import type { DashboardSidebarProject } from "./types";
import type { DashboardSidebarProject, DashboardSidebarSection } from "./types";
import { getProjectChildrenWorkspaces } from "./utils/projectChildren";

interface DashboardSidebarProps {
Expand Down Expand Up @@ -110,7 +110,7 @@ export function DashboardSidebar({
const {
groups,
pinnedWorkspaces,
sessionWorkspaces,
sessionsScope,
refreshWorkspacePullRequest,
toggleProjectCollapsed,
} = useDashboardSidebarData();
Expand Down Expand Up @@ -157,7 +157,7 @@ export function DashboardSidebar({

const workspaceShortcutLabels = useDashboardSidebarShortcuts(
orderedGroups,
sessionWorkspaces,
sessionsScope,
);
const selectableWorkspaceIds = useMemo(
() =>
Expand Down Expand Up @@ -185,6 +185,10 @@ export function DashboardSidebar({
if (pinned) {
return groups.find((project) => project.id === pinned.projectId) ?? null;
}
// Recursive: nested groups hold workspaces too.
const sectionContains = (section: DashboardSidebarSection): boolean =>
section.workspaces.some((ws) => ws.id === activeV2WorkspaceId) ||
section.childSections.some(sectionContains);
for (const project of groups) {
for (const child of project.children) {
if (
Expand All @@ -193,10 +197,8 @@ export function DashboardSidebar({
) {
return project;
}
if (child.type === "section") {
for (const ws of child.section.workspaces) {
if (ws.id === activeV2WorkspaceId) return project;
}
if (child.type === "section" && sectionContains(child.section)) {
return project;
}
}
}
Expand Down Expand Up @@ -242,7 +244,7 @@ export function DashboardSidebar({
/>
)}
<DashboardSidebarSessionsSection
sessionWorkspaces={sessionWorkspaces}
sessionsScope={sessionsScope}
isCollapsed={isCollapsed}
rowsHidden={!isCollapsed && workspacesListCollapsed}
onWorkspaceHover={refreshWorkspacePullRequest}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,171 @@
import { useCallback, useEffect, useState } from "react";
import { useDashboardSidebarSectionRename } from "renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarSectionRenameContext";
import { useDashboardSidebarState } from "renderer/routes/_authenticated/hooks/useDashboardSidebarState";
import { PROJECT_COLOR_DEFAULT } from "shared/constants/project-colors";
import type { DashboardSidebarSection } from "../../types";
import {
DashboardSidebarSectionActionsDropdown,
DashboardSidebarSectionContextMenu,
} from "../DashboardSidebarSection/components/DashboardSidebarSectionContextMenu";
import { DashboardSidebarSectionHeader } from "../DashboardSidebarSection/components/DashboardSidebarSectionHeader";
import { DashboardSidebarWorkspaceItem } from "../DashboardSidebarWorkspaceItem";

export interface NestedSectionSelection {
isWorkspaceSelected: (workspaceId: string) => boolean;
/** Returns true when the event was consumed as a selection gesture. */
onWorkspaceSelectionClick: (
event: Parameters<
NonNullable<
React.ComponentProps<
typeof DashboardSidebarWorkspaceItem
>["onSelectionClick"]
>
>[0],
workspaceId: string,
) => boolean;
}

interface DashboardSidebarNestedSectionProps {
section: DashboardSidebarSection;
/** 0 = a root group in the Sessions area; ≥1 = nested inside a group. */
depth: number;
workspaceShortcutLabels?: Map<string, string>;
/** Bulk-selection wiring (project scope only). */
selection?: NestedSectionSelection;
onWorkspaceHover: (workspaceId: string) => void | Promise<void>;
}

/**
* Recursive group renderer for the parts of the tree the flat DnD lane does
* not manage: groups nested inside a group, and the Sessions area's root
* groups. Children render folders-first (nested groups above the group's own
* workspaces); moves in and out happen through the context menus.
*/
export function DashboardSidebarNestedSection({
section,
depth,
workspaceShortcutLabels,
selection,
onWorkspaceHover,
}: DashboardSidebarNestedSectionProps) {
const {
deleteSection,
renameSection,
setSectionColor,
toggleSectionCollapsed,
} = useDashboardSidebarState();
const { clearPendingSectionRename, pendingRenameSectionId } =
useDashboardSidebarSectionRename();
const [isRenaming, setIsRenaming] = useState(false);
const [renameValue, setRenameValue] = useState(section.name);

const startRename = useCallback(() => {
setRenameValue(section.name);
setIsRenaming(true);
}, [section.name]);

useEffect(() => {
if (pendingRenameSectionId !== section.id) return;
startRename();
clearPendingSectionRename(section.id);
}, [
clearPendingSectionRename,
pendingRenameSectionId,
section.id,
startRename,
]);

const handleSubmitRename = () => {
const trimmed = renameValue.trim();
if (trimmed) renameSection(section.id, trimmed);
setIsRenaming(false);
};

const hasColor =
section.color != null && section.color !== PROJECT_COLOR_DEFAULT;

return (
<div style={{ paddingLeft: depth > 0 ? 10 : 0 }}>
<div
style={{
borderLeft: hasColor
? `2px solid ${section.color}`
: "2px solid var(--color-border)",
}}
>
<DashboardSidebarSectionContextMenu
color={section.color}
sectionId={section.id}
onRename={startRename}
onSetColor={(color) => setSectionColor(section.id, color)}
onDelete={() => deleteSection(section.id)}
>
<DashboardSidebarSectionHeader
section={section}
isRenaming={isRenaming}
renameValue={renameValue}
onRenameValueChange={setRenameValue}
onSubmitRename={handleSubmitRename}
onCancelRename={() => {
setRenameValue(section.name);
setIsRenaming(false);
}}
onToggleCollapse={() => toggleSectionCollapsed(section.id)}
actions={
<DashboardSidebarSectionActionsDropdown
color={section.color}
sectionId={section.id}
onRename={startRename}
onSetColor={(color) => setSectionColor(section.id, color)}
onDelete={() => deleteSection(section.id)}
/>
}
/>
</DashboardSidebarSectionContextMenu>
</div>
{!section.isCollapsed && (
<>
{section.childSections.map((child) => (
<DashboardSidebarNestedSection
key={child.id}
section={child}
depth={depth + 1}
workspaceShortcutLabels={workspaceShortcutLabels}
selection={selection}
onWorkspaceHover={onWorkspaceHover}
/>
))}
<div style={{ paddingLeft: depth > 0 ? 10 : 0 }}>
{section.workspaces.map((workspace) => {
const canBulkSelect =
selection !== undefined &&
workspace.type === "worktree" &&
workspace.pendingTransaction?.type !== "insert";
return (
<DashboardSidebarWorkspaceItem
key={workspace.id}
workspace={workspace}
isInSection
shortcutLabel={workspaceShortcutLabels?.get(workspace.id)}
isSelected={
canBulkSelect && selection.isWorkspaceSelected(workspace.id)
}
onSelectionClick={
canBulkSelect
? (event) =>
selection.onWorkspaceSelectionClick(
event,
workspace.id,
)
: undefined
}
onHoverCardOpen={() => onWorkspaceHover(workspace.id)}
/>
);
})}
</div>
</>
)}
</div>
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { DashboardSidebarNestedSection } from "./DashboardSidebarNestedSection";
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,11 @@ import { createPortal } from "react-dom";
import { useSidebarDnd } from "../../../../hooks/useSidebarDnd";
import { parseId } from "../../../../hooks/useSidebarDnd/useSidebarDnd";
import { useDashboardSidebarSelection } from "../../../../providers/DashboardSidebarSelectionProvider";
import type { DashboardSidebarProjectChild } from "../../../../types";
import type {
DashboardSidebarProjectChild,
DashboardSidebarSection,
} from "../../../../types";
import { DashboardSidebarNestedSection } from "../../../DashboardSidebarNestedSection";
import { WorkspaceBulkMenuScope } from "../../../DashboardSidebarWorkspaceItem/components/WorkspaceBulkMenuScope";
import { SidebarDragOverlay } from "../../../SidebarDragOverlay";
import { SortableSectionHeader } from "../../../SortableSectionHeader";
Expand Down Expand Up @@ -55,25 +59,48 @@ export function DashboardSidebarExpandedProjectContent({
const { isWorkspaceSelected, selectWorkspaceFromEvent } =
useDashboardSidebarSelection();

const selectableWorkspaceIds = useMemo(
() =>
flatItems.flatMap((id) => {
const parsed = parseId(id);
if (!parsed || parsed.type !== "workspace") return [];
const workspace = workspacesById.get(parsed.realId);
if (
!workspace ||
workspace.type !== "worktree" ||
workspace.pendingTransaction?.type === "insert"
) {
return [];
}
const group = groupInfo.get(parsed.realId);
if (group && collapsedSectionIds.has(group.sectionId)) return [];
return [parsed.realId];
}),
[flatItems, workspacesById, groupInfo, collapsedSectionIds],
);
// Range-select order matches the visual order: a section's nested
// subtree renders directly below its header (folders-first), before the
// section's direct workspaces from the flat lane.
const selectableWorkspaceIds = useMemo(() => {
const isSelectable = (workspaceId: string) => {
const workspace = workspacesById.get(workspaceId);
return (
!!workspace &&
workspace.type === "worktree" &&
workspace.pendingTransaction?.type !== "insert"
);
};
const collectVisibleSubtree = (
section: DashboardSidebarSection,
out: string[],
) => {
for (const child of section.childSections) {
if (child.isCollapsed) continue;
collectVisibleSubtree(child, out);
out.push(
...child.workspaces
.map((workspace) => workspace.id)
.filter(isSelectable),
);
}
};
return flatItems.flatMap((id) => {
const parsed = parseId(id);
if (!parsed) return [];
if (parsed.type === "section") {
const section = sectionsById.get(parsed.realId);
if (!section || collapsedSectionIds.has(section.id)) return [];
const nested: string[] = [];
collectVisibleSubtree(section, nested);
return nested;
}
if (!isSelectable(parsed.realId)) return [];
const group = groupInfo.get(parsed.realId);
if (group && collapsedSectionIds.has(group.sectionId)) return [];
return [parsed.realId];
});
}, [flatItems, workspacesById, sectionsById, groupInfo, collapsedSectionIds]);

return (
<AnimatePresence initial={false}>
Expand Down Expand Up @@ -108,15 +135,48 @@ export function DashboardSidebarExpandedProjectContent({
if (parsed.type === "section") {
const section = sectionsById.get(parsed.realId);
if (!section) return null;
// Nested groups render as a folders-first block
// under the header; the flat DnD lane manages only
// the header and the group's direct workspaces.
const showNested =
!section.isCollapsed &&
section.childSections.length > 0 &&
activeType !== "section";
return (
<SortableSectionHeader
key={String(id)}
sortableId={String(id)}
section={section}
onDelete={onDeleteSection}
onRename={onRenameSection}
onToggleCollapse={onToggleSectionCollapse}
/>
<div key={String(id)}>
<SortableSectionHeader
sortableId={String(id)}
section={section}
onDelete={onDeleteSection}
onRename={onRenameSection}
onToggleCollapse={onToggleSectionCollapse}
/>
{showNested &&
section.childSections.map((child) => (
<DashboardSidebarNestedSection
key={child.id}
section={child}
depth={1}
workspaceShortcutLabels={
workspaceShortcutLabels
}
selection={{
isWorkspaceSelected,
onWorkspaceSelectionClick: (
event,
workspaceId,
) =>
selectWorkspaceFromEvent(event, {
workspaceId,
projectId,
orderedWorkspaceIds:
selectableWorkspaceIds,
}),
}}
onWorkspaceHover={onWorkspaceHover}
/>
))}
</div>
);
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ interface DashboardSidebarSectionContextMenuProps
}

export function DashboardSidebarSectionContextMenu({
sectionId,
color,
onRename,
onSetColor,
Expand All @@ -27,6 +28,7 @@ export function DashboardSidebarSectionContextMenu({
onPointerDown={(event) => event.stopPropagation()}
>
<SectionActionsMenuItems
sectionId={sectionId}
color={color}
kind="context"
onRename={onRename}
Expand Down
Loading
Loading