diff --git a/src/components/command-palette/command-palette.tsx b/src/components/command-palette/command-palette.tsx index 071c0bd..9795e2d 100644 --- a/src/components/command-palette/command-palette.tsx +++ b/src/components/command-palette/command-palette.tsx @@ -4,11 +4,19 @@ import { getCurrentWindow } from "@tauri-apps/api/window"; import { isTauri } from "@tauri-apps/api/core"; import { useHotkey } from "@tanstack/react-hotkeys"; import { + Columns2, FolderPlus, + MoveDown, + MoveHorizontal, + MoveLeft, + MoveRight, + MoveUp, Maximize, MoonStar, MonitorUp, PanelLeft, + Rows2, + X, type LucideIcon, } from "lucide-react"; import { toast } from "sonner"; @@ -23,12 +31,26 @@ import { CommandSeparator, CommandShortcut, } from "@/components/ui/command"; -import { SIDEBAR_TOGGLE_EVENT } from "@/components/ui/sidebar"; +import { + CLOSE_PANE_LABEL, + dispatchWorkspaceCommand, + MOVE_DOWN_PANE_LABEL, + MOVE_LEFT_PANE_LABEL, + MOVE_RIGHT_PANE_LABEL, + MOVE_UP_PANE_LABEL, + NEXT_PANE_LABEL, + SPLIT_SIDE_BY_SIDE_LABEL, + SPLIT_STACKED_LABEL, +} from "@/components/projects-workspace/workspace-commands"; +import { + SIDEBAR_KEYBOARD_SHORTCUT_LABEL, + SIDEBAR_TOGGLE_EVENT, +} from "@/components/ui/sidebar"; import { useProjects } from "@/contexts/projects-context"; import { useTheme } from "@/contexts/theme-context"; import { processLogger } from "@/lib/logger"; -type CommandGroupKey = "Projects" | "Appearance" | "Window"; +type CommandGroupKey = "Projects" | "Workspace" | "Appearance" | "Window"; type PaletteCommand = { id: string; @@ -41,8 +63,14 @@ type PaletteCommand = { shortcutLabel?: string; }; -const GROUP_ORDER: CommandGroupKey[] = ["Projects", "Appearance", "Window"]; +const GROUP_ORDER: CommandGroupKey[] = ["Projects", "Workspace", "Appearance", "Window"]; +/** + * Convert an unknown error value into a user-facing message string. + * + * @param error - The error value to convert; may be an `Error`, a string, or any other value. + * @returns The error's message if `error` is an `Error` with a message, the trimmed string if `error` is a non-empty string, or `"Unexpected error"` otherwise. + */ function toErrorMessage(error: unknown): string { if (error instanceof Error && error.message) { return error.message; @@ -55,6 +83,14 @@ function toErrorMessage(error: unknown): string { return "Unexpected error"; } +/** + * Render a keyboard-driven command palette that exposes project, appearance, workspace, and window actions. + * + * The palette provides grouped, searchable commands with keyboard shortcuts and route- and environment-aware + * availability (e.g., workspace and sidebar commands require the `/projects` route; fullscreen commands require Tauri). + * + * @returns The React element for the command palette controlled by internal open/close state and hotkeys. + */ export function CommandPalette() { const location = useLocation(); const { openProject, openProjectInSeparateWindow, selectedProjectId } = @@ -62,6 +98,7 @@ export function CommandPalette() { const { resolvedTheme, toggleThemePreference } = useTheme(); const [isOpen, setIsOpen] = useState(false); const isSidebarAvailable = location.pathname.startsWith("/projects"); + const isWorkspaceAvailable = location.pathname.startsWith("/projects"); const toggleFullscreen = useCallback(async () => { if (!isTauri()) { return; @@ -115,6 +152,102 @@ export function CommandPalette() { keywords: ["theme", "appearance", "dark", "light", "mode"], shortcutLabel: "Mod+Shift+T", }, + { + id: "split-pane-side-by-side", + label: "Split pane side by side", + group: "Workspace", + icon: Columns2, + onSelect: () => { + dispatchWorkspaceCommand("split-horizontal"); + }, + disabled: !isWorkspaceAvailable, + keywords: ["workspace", "pane", "split", "horizontal", "vertical", "multiplexer"], + shortcutLabel: SPLIT_SIDE_BY_SIDE_LABEL, + }, + { + id: "split-pane-stacked", + label: "Split pane stacked", + group: "Workspace", + icon: Rows2, + onSelect: () => { + dispatchWorkspaceCommand("split-vertical"); + }, + disabled: !isWorkspaceAvailable, + keywords: ["workspace", "pane", "split", "vertical", "stacked", "multiplexer"], + shortcutLabel: SPLIT_STACKED_LABEL, + }, + { + id: "close-focused-pane", + label: "Close focused pane", + group: "Workspace", + icon: X, + onSelect: () => { + dispatchWorkspaceCommand("close-pane"); + }, + disabled: !isWorkspaceAvailable, + keywords: ["workspace", "pane", "close", "remove", "multiplexer"], + shortcutLabel: CLOSE_PANE_LABEL, + }, + { + id: "focus-next-pane", + label: "Focus next pane", + group: "Workspace", + icon: MoveHorizontal, + onSelect: () => { + dispatchWorkspaceCommand("next-pane"); + }, + disabled: !isWorkspaceAvailable, + keywords: ["workspace", "pane", "focus", "next", "cycle", "multiplexer"], + shortcutLabel: NEXT_PANE_LABEL, + }, + { + id: "focus-pane-left", + label: "Focus pane left", + group: "Workspace", + icon: MoveLeft, + onSelect: () => { + dispatchWorkspaceCommand("focus-left"); + }, + disabled: !isWorkspaceAvailable, + keywords: ["workspace", "pane", "focus", "left", "multiplexer"], + shortcutLabel: MOVE_LEFT_PANE_LABEL, + }, + { + id: "focus-pane-right", + label: "Focus pane right", + group: "Workspace", + icon: MoveRight, + onSelect: () => { + dispatchWorkspaceCommand("focus-right"); + }, + disabled: !isWorkspaceAvailable, + keywords: ["workspace", "pane", "focus", "right", "multiplexer"], + shortcutLabel: MOVE_RIGHT_PANE_LABEL, + }, + { + id: "focus-pane-up", + label: "Focus pane up", + group: "Workspace", + icon: MoveUp, + onSelect: () => { + dispatchWorkspaceCommand("focus-up"); + }, + disabled: !isWorkspaceAvailable, + keywords: ["workspace", "pane", "focus", "up", "multiplexer"], + shortcutLabel: MOVE_UP_PANE_LABEL, + }, + { + id: "focus-pane-down", + label: "Focus pane down", + group: "Workspace", + icon: MoveDown, + onSelect: () => { + dispatchWorkspaceCommand("focus-down"); + }, + disabled: !isWorkspaceAvailable, + keywords: ["workspace", "pane", "focus", "down", "multiplexer"], + shortcutLabel: MOVE_DOWN_PANE_LABEL, + }, { id: "toggle-fullscreen", label: "Toggle fullscreen", @@ -135,11 +268,12 @@ export function CommandPalette() { }, disabled: !isSidebarAvailable, keywords: ["sidebar", "panel", "navigation", "project"], - shortcutLabel: "Mod+B", + shortcutLabel: SIDEBAR_KEYBOARD_SHORTCUT_LABEL, }, ], [ isSidebarAvailable, + isWorkspaceAvailable, openProject, openProjectInSeparateWindow, resolvedTheme, diff --git a/src/components/project-board/project-board-view.tsx b/src/components/project-board/project-board-view.tsx new file mode 100644 index 0000000..f3ace74 --- /dev/null +++ b/src/components/project-board/project-board-view.tsx @@ -0,0 +1,196 @@ +import { useMemo, useState } from "react"; +import { + BoardSummaryRail, + compareTasksByRecentActivity, + CreateTaskDialog, + EmptyProjectBoard, + filterBoardTasks, + groupSubtasksByParentId, + groupTasksByColumn, + indexTasksById, + KANBAN_COLUMNS, + KanbanColumn, + ProjectBoardHeader, + summarizeBoardTasks, + summarizeSubtaskProgress, + TaskDetailsDialog, + type KanbanColumnKey, + type TaskFilterKey, +} from "@/components/project-board"; +import type { DexTask } from "@/lib/tasks-service"; +import type { ProjectItem } from "@/lib/projects-service"; + +type ProjectBoardViewProps = { + project: ProjectItem; + projectTasks: DexTask[]; +}; + +/** + * Render a kanban-style project board for a given project and its tasks. + * + * Displays a header with project metadata, a filterable summary rail, four kanban columns + * populated from `projectTasks` (or an empty-state when there are no tasks), and the + * CreateTask and TaskDetails dialogs used to add and inspect tasks. + * + * @param project - Project metadata (name, path, and other display fields) used in the header and dialogs + * @param projectTasks - Array of tasks for the project; used to compute sorting, grouping, summaries, relation options, and per-task subtask progress + * @returns The board UI as JSX: header, optional summary rail and kanban columns (or empty state), plus create-task and task-details dialogs + */ +export function ProjectBoardView({ project, projectTasks }: ProjectBoardViewProps) { + const [isCreateTaskOpen, setIsCreateTaskOpen] = useState(false); + const [isDetailsOpen, setIsDetailsOpen] = useState(false); + const [selectedTaskId, setSelectedTaskId] = useState(null); + const [taskFilter, setTaskFilter] = useState("all"); + const [collapsedColumns, setCollapsedColumns] = useState>({ + todo: false, + inProgress: false, + blocked: false, + done: false, + }); + + const sortedProjectTasks = useMemo( + () => [...projectTasks].sort(compareTasksByRecentActivity), + [projectTasks], + ); + + const groupedTasks = useMemo(() => groupTasksByColumn(sortedProjectTasks), [sortedProjectTasks]); + + const summary = useMemo( + () => summarizeBoardTasks(sortedProjectTasks, groupedTasks), + [groupedTasks, sortedProjectTasks], + ); + + const filteredGroupedTasks = useMemo( + () => filterBoardTasks(groupedTasks, taskFilter), + [groupedTasks, taskFilter], + ); + + const taskById = useMemo(() => indexTasksById(sortedProjectTasks), [sortedProjectTasks]); + + const subtasksByParentId = useMemo( + () => groupSubtasksByParentId(sortedProjectTasks), + [sortedProjectTasks], + ); + + const subtaskProgressByTaskId = useMemo( + () => summarizeSubtaskProgress(subtasksByParentId), + [subtasksByParentId], + ); + + const selectedTask = useMemo(() => { + if (!selectedTaskId) { + return null; + } + + return sortedProjectTasks.find((task) => task.id === selectedTaskId) ?? null; + }, [selectedTaskId, sortedProjectTasks]); + + const selectedTaskParent = useMemo(() => { + if (!selectedTask?.parentId) { + return null; + } + + return taskById.get(selectedTask.parentId) ?? null; + }, [selectedTask, taskById]); + + const selectedTaskSubtasks = useMemo(() => { + if (!selectedTask) { + return []; + } + + return subtasksByParentId.get(selectedTask.id) ?? []; + }, [selectedTask, subtasksByParentId]); + + const taskRelationOptions = sortedProjectTasks; + + const getSubtaskProgress = (taskId: string) => subtaskProgressByTaskId.get(taskId); + + const openTaskDetails = (taskId: string) => { + setSelectedTaskId(taskId); + setIsDetailsOpen(true); + }; + + const handleDetailsOpenChange = (open: boolean) => { + setIsDetailsOpen(open); + if (!open) { + setSelectedTaskId(null); + } + }; + + const toggleColumnCollapsed = (columnKey: KanbanColumnKey) => { + setCollapsedColumns((current) => ({ + ...current, + [columnKey]: !current[columnKey], + })); + }; + + return ( + <> +
+
+
+ { + setIsCreateTaskOpen(true); + }} + openTasks={summary.open} + projectName={project.name} + projectPath={project.path} + totalTasks={summary.total} + /> + + {projectTasks.length > 0 ? ( + <> + +
+ {KANBAN_COLUMNS.map((column) => ( + toggleColumnCollapsed(column.key)} + tasks={filteredGroupedTasks[column.key]} + /> + ))} +
+ + ) : ( + { + setIsCreateTaskOpen(true); + }} + projectName={project.name} + /> + )} +
+
+
+ + + + + + ); +} diff --git a/src/components/project-sidebar/project-sidebar.tsx b/src/components/project-sidebar/project-sidebar.tsx index 2a6947d..4bd144f 100644 --- a/src/components/project-sidebar/project-sidebar.tsx +++ b/src/components/project-sidebar/project-sidebar.tsx @@ -1,4 +1,3 @@ -import { useNavigate } from "@tanstack/react-router"; import { FolderKanban, Plus } from "lucide-react"; import { Sidebar, @@ -14,10 +13,20 @@ import { import { useProjects } from "@/contexts/projects-context"; import { ProjectListItem } from "./project-list-item"; -export function ProjectSidebar() { - const navigate = useNavigate(); - const { deleteProject, openProject, openProjectInSeparateWindow, projects, selectedProjectId } = - useProjects(); +type ProjectSidebarProps = { + selectedProjectId: string | null; + onSelectProject: (projectId: string) => void; +}; + +/** + * Render the Projects sidebar with a list of projects and controls to add, open, or delete projects. + * + * @param selectedProjectId - Id of the currently selected project; used to mark the selected list item. + * @param onSelectProject - Callback invoked with a project's id when that project is selected. + * @returns The sidebar React element containing the projects list, empty state, and action controls. + */ +export function ProjectSidebar({ selectedProjectId, onSelectProject }: ProjectSidebarProps) { + const { deleteProject, openProject, openProjectInSeparateWindow, projects } = useProjects(); return ( { - navigate({ - to: "/projects/$projectId", - params: { projectId: project.id }, - }); + onSelectProject(project.id); }} project={project} /> diff --git a/src/components/projects-workspace/projects-workspace.tsx b/src/components/projects-workspace/projects-workspace.tsx new file mode 100644 index 0000000..6ff70c7 --- /dev/null +++ b/src/components/projects-workspace/projects-workspace.tsx @@ -0,0 +1,469 @@ +import { useEffect, useMemo } from "react"; +import { useHotkeySequence } from "@tanstack/react-hotkeys"; +import type { HotkeySequence } from "@tanstack/hotkeys"; +import { Columns2, Rows2, SquareDashed, X } from "lucide-react"; +import { ProjectBoardPlaceholder, ProjectBoardView } from "@/components/project-board"; +import { ProjectSidebar } from "@/components/project-sidebar"; +import { Button } from "@/components/ui/button"; +import { + ResizableHandle, + ResizablePanel, + ResizablePanelGroup, +} from "@/components/ui/resizable"; +import { SidebarInset, SidebarProvider, SidebarTrigger } from "@/components/ui/sidebar"; +import { useProjects } from "@/contexts/projects-context"; +import { useTasks } from "@/contexts/tasks-context"; +import { cn } from "@/lib/utils"; +import { WorkspaceProvider, useWorkspace } from "./workspace-context"; +import { + CLOSE_PANE_LABEL, + SPLIT_SIDE_BY_SIDE_LABEL, + SPLIT_STACKED_LABEL, + TMUX_PREFIX_LABEL, + WORKSPACE_COMMAND_EVENT, + type WorkspaceCommand, +} from "./workspace-commands"; +import { type WorkspaceNode } from "./workspace-state"; + +type ProjectsWorkspaceProps = { + initialProjectId: string | null; + routeWorkspaceState: string | null; + onWorkspaceStateChange: (serializedState: string) => void; +}; + +type WorkspaceNodeViewProps = { + node: WorkspaceNode; + paneCount: number; +}; + +/** + * Produce a human-readable label for the number of open panes. + * + * @param count - The total number of open panes + * @returns `"1 pane open"` if `count` is 1, otherwise `" panes open"` + */ +function getPaneCountLabel(count: number): string { + return count === 1 ? "1 pane open" : `${count} panes open`; +} + +const TMUX_SPLIT_SIDE_BY_SIDE_SEQUENCE = ["Control+B", "%"] as unknown as HotkeySequence; +const TMUX_SPLIT_STACKED_SEQUENCE = ["Control+B", '"'] as unknown as HotkeySequence; +const TMUX_CLOSE_SEQUENCE = ["Control+B", "X"] as HotkeySequence; +const TMUX_NEXT_PANE_SEQUENCE = ["Control+B", "O"] as HotkeySequence; +const TMUX_MOVE_LEFT_SEQUENCE = ["Control+B", "ArrowLeft"] as HotkeySequence; +const TMUX_MOVE_RIGHT_SEQUENCE = ["Control+B", "ArrowRight"] as HotkeySequence; +const TMUX_MOVE_UP_SEQUENCE = ["Control+B", "ArrowUp"] as HotkeySequence; +const TMUX_MOVE_DOWN_SEQUENCE = ["Control+B", "ArrowDown"] as HotkeySequence; + +/** + * Renders a centered placeholder UI for an empty workspace pane. + * + * Displays an "Empty pane" message with a contextual hint: if any projects exist, prompts the user to pick one from the sidebar; otherwise prompts to add a project first. + * + * @returns The JSX element for the empty-pane placeholder view + */ +function WorkspacePanePlaceholder() { + const { projects } = useProjects(); + + return ( +
+
+
+ +
+
+

Empty pane

+

+ {projects.length > 0 + ? "Pick a project from the sidebar to load it in this pane." + : "Add a project first, then assign it to this pane from the sidebar."} +

+
+
+
+ ); +} + +/** + * Render a workspace node as either a resizable split group or a leaf pane with project content. + * + * Renders a recursive resizable split when `node.kind === "split"`, otherwise renders a leaf pane + * header (project name, pane index and controls) and either the assigned project's board or a placeholder. + * + * @param node - The workspace node to render; may be a split node with two children or a leaf pane with an optional `projectId`. + * @param paneCount - Total number of open panes in the workspace; used for display and to disable the close action when `paneCount` is 1. + * @returns The React element representing the given workspace node. + */ +function WorkspaceNodeView({ node, paneCount }: WorkspaceNodeViewProps) { + const { projects } = useProjects(); + const { getProjectTasks } = useTasks(); + const { + closePane, + focusPane, + focusedPaneId, + splitPane, + updateSplitLayout, + } = useWorkspace(); + + const projectsById = useMemo(() => new Map(projects.map((project) => [project.id, project])), [projects]); + + if (node.kind === "split") { + const [firstChild, secondChild] = node.children; + + return ( + { + updateSplitLayout(node.id, [ + layout[firstChild.id] ?? node.sizes[0], + layout[secondChild.id] ?? node.sizes[1], + ]); + }} + orientation={node.axis} + > + + + + + + + + + ); + } + + const project = node.projectId ? projectsById.get(node.projectId) ?? null : null; + const projectTasks = getProjectTasks(project?.path ?? null); + const isFocused = focusedPaneId === node.id; + + return ( +
{ + focusPane(node.id); + }} + > +
+
+

+ {project?.name ?? "No project selected"} +

+

+ Pane {node.id.replace("pane-", "")} • {getPaneCountLabel(paneCount)} +

+
+ +
+ + + +
+
+ +
+ {project ? : } +
+
+ ); +} + +/** + * Render the projects workspace shell, including the project sidebar, workspace header, resizable pane layout, and bindings for workspace commands and tmux-like hotkeys. + * + * The component also selects the focused project when focus changes and exposes controls for splitting, closing, and moving focus between panes. + * + * @returns The workspace UI element (project sidebar, header, and resizable workspace). If the workspace is not ready, returns a ProjectBoardPlaceholder. + */ +function ProjectsWorkspaceShell() { + const { projects, selectProject } = useProjects(); + const { + assignProjectToFocusedPane, + focusedProjectId, + isWorkspaceReady, + focusNextPane, + moveFocus, + panes, + splitFocusedPane, + workspaceState, + closeFocusedPane, + } = useWorkspace(); + + const focusedProject = useMemo( + () => projects.find((project) => project.id === focusedProjectId) ?? null, + [focusedProjectId, projects], + ); + + useEffect(() => { + selectProject(focusedProjectId); + }, [focusedProjectId, selectProject]); + + useEffect(() => { + const handleWorkspaceCommand = (event: Event) => { + const command = (event as CustomEvent).detail; + + if (command === "split-horizontal") { + splitFocusedPane("horizontal"); + return; + } + + if (command === "split-vertical") { + splitFocusedPane("vertical"); + return; + } + + if (command === "close-pane") { + closeFocusedPane(); + return; + } + + if (command === "next-pane") { + focusNextPane(); + return; + } + + if (command === "focus-left") { + moveFocus("left"); + return; + } + + if (command === "focus-right") { + moveFocus("right"); + return; + } + + if (command === "focus-up") { + moveFocus("up"); + return; + } + + if (command === "focus-down") { + moveFocus("down"); + } + }; + + window.addEventListener(WORKSPACE_COMMAND_EVENT, handleWorkspaceCommand); + return () => { + window.removeEventListener(WORKSPACE_COMMAND_EVENT, handleWorkspaceCommand); + }; + }, [closeFocusedPane, focusNextPane, moveFocus, splitFocusedPane]); + + useHotkeySequence( + TMUX_SPLIT_SIDE_BY_SIDE_SEQUENCE, + () => { + splitFocusedPane("horizontal"); + }, + { + enabled: isWorkspaceReady, + ignoreInputs: true, + preventDefault: true, + timeout: 1200, + }, + ); + + useHotkeySequence( + TMUX_SPLIT_STACKED_SEQUENCE, + () => { + splitFocusedPane("vertical"); + }, + { + enabled: isWorkspaceReady, + ignoreInputs: true, + preventDefault: true, + timeout: 1200, + }, + ); + + useHotkeySequence( + TMUX_CLOSE_SEQUENCE, + () => { + closeFocusedPane(); + }, + { + enabled: isWorkspaceReady, + ignoreInputs: true, + preventDefault: true, + timeout: 1200, + }, + ); + + useHotkeySequence( + TMUX_NEXT_PANE_SEQUENCE, + () => { + focusNextPane(); + }, + { + enabled: isWorkspaceReady, + ignoreInputs: true, + preventDefault: true, + timeout: 1200, + }, + ); + + useHotkeySequence( + TMUX_MOVE_LEFT_SEQUENCE, + () => { + moveFocus("left"); + }, + { + enabled: isWorkspaceReady, + ignoreInputs: true, + preventDefault: true, + timeout: 1200, + }, + ); + + useHotkeySequence( + TMUX_MOVE_RIGHT_SEQUENCE, + () => { + moveFocus("right"); + }, + { + enabled: isWorkspaceReady, + ignoreInputs: true, + preventDefault: true, + timeout: 1200, + }, + ); + + useHotkeySequence( + TMUX_MOVE_UP_SEQUENCE, + () => { + moveFocus("up"); + }, + { + enabled: isWorkspaceReady, + ignoreInputs: true, + preventDefault: true, + timeout: 1200, + }, + ); + + useHotkeySequence( + TMUX_MOVE_DOWN_SEQUENCE, + () => { + moveFocus("down"); + }, + { + enabled: isWorkspaceReady, + ignoreInputs: true, + preventDefault: true, + timeout: 1200, + }, + ); + + if (!isWorkspaceReady) { + return ; + } + + return ( + + { + assignProjectToFocusedPane(projectId); + }} + selectedProjectId={focusedProjectId} + /> + +
+ +
+

Workspace

+

+ {focusedProject?.name ?? "Focused pane is empty"} +

+
+
+

{getPaneCountLabel(panes.length)}

+

+ {TMUX_PREFIX_LABEL} then % / " / x / o / arrows +

+
+
+ +
+
+ +
+
+
+
+ ); +} + +/** + * Render the projects workspace, gating rendering on project initialization and providing workspace context. + * + * @param initialProjectId - Optional project id to focus when the workspace initializes + * @param routeWorkspaceState - Optional serialized workspace state from the route to restore layout/selection + * @param onWorkspaceStateChange - Callback invoked when the workspace state changes + * @returns The workspace UI element when projects are initialized; otherwise a placeholder indicating projects are not ready + */ +export function ProjectsWorkspace({ + initialProjectId, + routeWorkspaceState, + onWorkspaceStateChange, +}: ProjectsWorkspaceProps) { + const { isProjectsInitialized, projects } = useProjects(); + const projectIds = useMemo(() => projects.map((project) => project.id), [projects]); + + if (!isProjectsInitialized) { + return ; + } + + return ( + + + + ); +} diff --git a/src/components/projects-workspace/workspace-commands.ts b/src/components/projects-workspace/workspace-commands.ts new file mode 100644 index 0000000..ca83ee5 --- /dev/null +++ b/src/components/projects-workspace/workspace-commands.ts @@ -0,0 +1,34 @@ +export const TMUX_PREFIX_LABEL = "Ctrl+B"; +export const SPLIT_SIDE_BY_SIDE_LABEL = `${TMUX_PREFIX_LABEL}, %`; +export const SPLIT_STACKED_LABEL = `${TMUX_PREFIX_LABEL}, "`; +export const CLOSE_PANE_LABEL = `${TMUX_PREFIX_LABEL}, X`; +export const NEXT_PANE_LABEL = `${TMUX_PREFIX_LABEL}, O`; +export const MOVE_LEFT_PANE_LABEL = `${TMUX_PREFIX_LABEL}, Left`; +export const MOVE_RIGHT_PANE_LABEL = `${TMUX_PREFIX_LABEL}, Right`; +export const MOVE_UP_PANE_LABEL = `${TMUX_PREFIX_LABEL}, Up`; +export const MOVE_DOWN_PANE_LABEL = `${TMUX_PREFIX_LABEL}, Down`; + +export const WORKSPACE_COMMAND_EVENT = "dextop:workspace-command"; + +export type WorkspaceCommand = + | "split-horizontal" + | "split-vertical" + | "close-pane" + | "next-pane" + | "focus-left" + | "focus-right" + | "focus-up" + | "focus-down"; + +/** + * Dispatches a workspace command as a CustomEvent on `window`. + * + * @param command - The workspace command to dispatch; the emitted event's `detail` will contain this value. + */ +export function dispatchWorkspaceCommand(command: WorkspaceCommand): void { + window.dispatchEvent( + new CustomEvent(WORKSPACE_COMMAND_EVENT, { + detail: command, + }), + ); +} diff --git a/src/components/projects-workspace/workspace-context.tsx b/src/components/projects-workspace/workspace-context.tsx new file mode 100644 index 0000000..c06ab10 --- /dev/null +++ b/src/components/projects-workspace/workspace-context.tsx @@ -0,0 +1,286 @@ +import { + createContext, + useCallback, + useContext, + useEffect, + useMemo, + useRef, + useState, + type ReactNode, +} from "react"; +import { getCurrentWindow } from "@tauri-apps/api/window"; +import { + assignProjectToPane, + closeWorkspacePane, + createWorkspaceState, + focusNextWorkspacePane, + focusWorkspacePane, + getWorkspacePaneById, + listWorkspacePanes, + moveWorkspaceFocus, + normalizeWorkspaceState, + serializeWorkspaceState, + splitWorkspacePane, + updateSplitSizes, + type WorkspaceMoveDirection, + type WorkspacePaneNode, + type WorkspaceSplitAxis, + type WorkspaceState, +} from "./workspace-state"; + +const WORKSPACE_STORAGE_KEY = "dextop-project-workspace"; + +type WorkspaceContextValue = { + workspaceState: WorkspaceState; + panes: WorkspacePaneNode[]; + focusedPaneId: string; + focusedProjectId: string | null; + isWorkspaceReady: boolean; + splitPane: (paneId: string, axis: WorkspaceSplitAxis) => void; + splitFocusedPane: (axis: WorkspaceSplitAxis) => void; + closePane: (paneId: string) => void; + closeFocusedPane: () => void; + focusPane: (paneId: string) => void; + focusNextPane: () => void; + moveFocus: (direction: WorkspaceMoveDirection) => void; + assignProjectToPane: (paneId: string, projectId: string | null) => void; + assignProjectToFocusedPane: (projectId: string | null) => void; + updateSplitLayout: (splitId: string, sizes: [number, number] | number[]) => void; +}; + +const WorkspaceContext = createContext(undefined); + +/** + * Compute a storage scope identifier used to namespace persisted workspace state. + * + * If a Tauri window label is available and non-empty, that label is returned. + * Otherwise the current `window.location.pathname` is returned with non-alphanumeric + * characters replaced by `-`. If `window` is unavailable or the resulting scope + * is empty, `"main"` is returned. + * + * @returns A storage scope string: the Tauri window label when present, otherwise + * the sanitized pathname, or `"main"` as a fallback. + */ +function getWorkspaceStorageScope(): string { + if (typeof window === "undefined") { + return "main"; + } + + try { + const currentWindow = getCurrentWindow(); + if (typeof currentWindow.label === "string" && currentWindow.label.trim()) { + return currentWindow.label; + } + } catch { + // Ignore non-tauri environments such as tests. + } + + return window.location.pathname.replace(/[^a-z0-9]+/gi, "-") || "main"; +} + +type WorkspaceProviderProps = { + children: ReactNode; + initialProjectId: string | null; + projectIds: string[]; + routeWorkspaceState: string | null; + onWorkspaceStateChange: (serializedState: string) => void; +}; + +/** + * Provides workspace state, derived pane/project focus data, and actions to manipulate the workspace to any descendant components via WorkspaceContext. + * + * @param children - React children to render inside the provider + * @param initialProjectId - Optional project ID used when creating an initial workspace state (ignored if not in the provided projectIds) + * @param projectIds - List of valid project IDs used to normalize persisted or incoming workspace state + * @param routeWorkspaceState - Optional serialized workspace state (e.g., from a route) that takes precedence during initial hydration + * @param onWorkspaceStateChange - Callback invoked with the serialized workspace state when it changes (only after initial hydration) + * @returns The WorkspaceContext provider element that renders `children` + */ +export function WorkspaceProvider({ + children, + initialProjectId, + projectIds, + routeWorkspaceState, + onWorkspaceStateChange, +}: WorkspaceProviderProps) { + const hasHydratedRef = useRef(false); + const [storageKey, setStorageKey] = useState(null); + const [isWorkspaceReady, setIsWorkspaceReady] = useState(false); + const [workspaceState, setWorkspaceState] = useState(() => + createWorkspaceState(initialProjectId), + ); + + const validProjectIds = useMemo(() => new Set(projectIds), [projectIds]); + + useEffect(() => { + if (hasHydratedRef.current || typeof window === "undefined") { + return; + } + + const key = `${WORKSPACE_STORAGE_KEY}:${getWorkspaceStorageScope()}`; + setStorageKey(key); + + const storedValue = routeWorkspaceState ?? window.localStorage.getItem(key); + let parsedValue: unknown = null; + + if (storedValue) { + try { + parsedValue = JSON.parse(storedValue); + } catch { + parsedValue = null; + } + } + + setWorkspaceState( + normalizeWorkspaceState( + parsedValue, + validProjectIds, + initialProjectId && validProjectIds.has(initialProjectId) ? initialProjectId : null, + ), + ); + setIsWorkspaceReady(true); + hasHydratedRef.current = true; + }, [initialProjectId, routeWorkspaceState, validProjectIds]); + + useEffect(() => { + if (!hasHydratedRef.current) { + return; + } + + setWorkspaceState((currentState) => + normalizeWorkspaceState( + currentState, + validProjectIds, + initialProjectId && validProjectIds.has(initialProjectId) ? initialProjectId : null, + ), + ); + }, [initialProjectId, validProjectIds]); + + useEffect(() => { + if (!storageKey || !isWorkspaceReady || typeof window === "undefined") { + return; + } + + const serializedState = serializeWorkspaceState(workspaceState); + window.localStorage.setItem(storageKey, serializedState); + + if (serializedState !== routeWorkspaceState) { + onWorkspaceStateChange(serializedState); + } + }, [ + isWorkspaceReady, + onWorkspaceStateChange, + routeWorkspaceState, + storageKey, + workspaceState, + ]); + + const panes = useMemo(() => listWorkspacePanes(workspaceState.root), [workspaceState.root]); + + const splitPane = useCallback((paneId: string, axis: WorkspaceSplitAxis) => { + setWorkspaceState((currentState) => splitWorkspacePane(currentState, paneId, axis)); + }, []); + + const splitFocusedPane = useCallback((axis: WorkspaceSplitAxis) => { + setWorkspaceState((currentState) => + splitWorkspacePane(currentState, currentState.focusedPaneId, axis), + ); + }, []); + + const closePane = useCallback((paneId: string) => { + setWorkspaceState((currentState) => closeWorkspacePane(currentState, paneId)); + }, []); + + const closeFocusedPane = useCallback(() => { + setWorkspaceState((currentState) => + closeWorkspacePane(currentState, currentState.focusedPaneId), + ); + }, []); + + const focusPane = useCallback((paneId: string) => { + setWorkspaceState((currentState) => focusWorkspacePane(currentState, paneId)); + }, []); + + const focusNextPane = useCallback(() => { + setWorkspaceState((currentState) => focusNextWorkspacePane(currentState)); + }, []); + + const moveFocus = useCallback((direction: WorkspaceMoveDirection) => { + setWorkspaceState((currentState) => moveWorkspaceFocus(currentState, direction)); + }, []); + + const assignProject = useCallback((paneId: string, projectId: string | null) => { + setWorkspaceState((currentState) => assignProjectToPane(currentState, paneId, projectId)); + }, []); + + const assignProjectToFocusedPane = useCallback((projectId: string | null) => { + setWorkspaceState((currentState) => + assignProjectToPane(currentState, currentState.focusedPaneId, projectId), + ); + }, []); + + const updateSplitLayout = useCallback( + (splitId: string, sizes: [number, number] | number[]) => { + setWorkspaceState((currentState) => updateSplitSizes(currentState, splitId, sizes)); + }, + [], + ); + + const focusedProjectId = useMemo( + () => getWorkspacePaneById(workspaceState.root, workspaceState.focusedPaneId)?.projectId ?? null, + [workspaceState.focusedPaneId, workspaceState.root], + ); + + const value = useMemo( + () => ({ + workspaceState, + panes, + focusedPaneId: workspaceState.focusedPaneId, + focusedProjectId, + isWorkspaceReady, + splitPane, + splitFocusedPane, + closePane, + closeFocusedPane, + focusPane, + focusNextPane, + moveFocus, + assignProjectToPane: assignProject, + assignProjectToFocusedPane, + updateSplitLayout, + }), + [ + assignProject, + assignProjectToFocusedPane, + closePane, + closeFocusedPane, + focusPane, + focusNextPane, + focusedProjectId, + isWorkspaceReady, + moveFocus, + panes, + splitPane, + splitFocusedPane, + updateSplitLayout, + workspaceState, + ], + ); + + return {children}; +} + +/** + * Access the workspace context for the current React tree. + * + * @returns The current WorkspaceContextValue containing the workspace state, derived pane/project metadata, readiness flag, and action handlers. + * @throws {Error} If called outside of a WorkspaceProvider. + */ +export function useWorkspace(): WorkspaceContextValue { + const context = useContext(WorkspaceContext); + if (!context) { + throw new Error("useWorkspace must be used within a WorkspaceProvider"); + } + + return context; +} diff --git a/src/components/projects-workspace/workspace-state.ts b/src/components/projects-workspace/workspace-state.ts new file mode 100644 index 0000000..ea75aab --- /dev/null +++ b/src/components/projects-workspace/workspace-state.ts @@ -0,0 +1,795 @@ +export type WorkspaceSplitAxis = "horizontal" | "vertical"; +export type WorkspaceMoveDirection = "left" | "right" | "up" | "down"; + +export type WorkspacePaneNode = { + kind: "pane"; + id: string; + projectId: string | null; +}; + +export type WorkspaceSplitNode = { + kind: "split"; + id: string; + axis: WorkspaceSplitAxis; + sizes: [number, number]; + children: [WorkspaceNode, WorkspaceNode]; +}; + +export type WorkspaceNode = WorkspacePaneNode | WorkspaceSplitNode; + +export type WorkspaceState = { + root: WorkspaceNode; + focusedPaneId: string; + nextPaneNumber: number; + nextSplitNumber: number; +}; + +type WorkspaceRect = { + x: number; + y: number; + width: number; + height: number; +}; + +const DEFAULT_SIZES: [number, number] = [50, 50]; + +/** + * Create a pane node identifier from a sequential pane number. + * + * @param nextPaneNumber - The sequential number used to form the pane id + * @returns The pane id string in the form `pane-N` where `N` is `nextPaneNumber` + */ +function createPaneId(nextPaneNumber: number): string { + return `pane-${nextPaneNumber}`; +} + +/** + * Create a split node identifier from the provided split counter. + * + * @param nextSplitNumber - Monotonic counter used to construct the identifier + * @returns The split id in the form `split-{n}`, where `{n}` is `nextSplitNumber` + */ +function createSplitId(nextSplitNumber: number): string { + return `split-${nextSplitNumber}`; +} + +/** + * Creates a workspace pane node with a generated id and an optional project assignment. + * + * @param projectId - The project id to assign to the pane, or `null` for none. + * @param nextPaneNumber - Numeric suffix used to generate the pane's `id`. + * @returns A `WorkspacePaneNode` whose `id` is `pane-${nextPaneNumber}` and `projectId` set as provided. + */ +function createPane(projectId: string | null, nextPaneNumber: number): WorkspacePaneNode { + return { + kind: "pane", + id: createPaneId(nextPaneNumber), + projectId, + }; +} + +/** + * Create an initial workspace state containing a single pane. + * + * @param projectId - The project id to assign to the initial pane, or `null` for an empty pane + * @returns A WorkspaceState whose root is a single pane assigned the provided `projectId` (or `null`), `focusedPaneId` set to that pane, `nextPaneNumber` set to `2`, and `nextSplitNumber` set to `1` + */ +export function createWorkspaceState(projectId: string | null = null): WorkspaceState { + return { + root: createPane(projectId, 1), + focusedPaneId: createPaneId(1), + nextPaneNumber: 2, + nextSplitNumber: 1, + }; +} + +/** + * Type guard that checks whether a workspace node represents a pane. + * + * @returns `true` if the node is a pane node (narrows to `WorkspacePaneNode`), `false` otherwise. + */ +export function isWorkspacePaneNode(node: WorkspaceNode): node is WorkspacePaneNode { + return node.kind === "pane"; +} + +/** + * Collects all pane nodes contained in the given subtree in left-to-right order. + * + * @param node - The workspace node to traverse (pane or split) + * @returns An array of `WorkspacePaneNode` instances found in the subtree, ordered by traversal (left child before right child) + */ +export function listWorkspacePanes(node: WorkspaceNode): WorkspacePaneNode[] { + if (node.kind === "pane") { + return [node]; + } + + return [...listWorkspacePanes(node.children[0]), ...listWorkspacePanes(node.children[1])]; +} + +/** + * Locate a pane node with the given id within the subtree. + * + * @returns The matching `WorkspacePaneNode` if found, `null` otherwise. + */ +export function getWorkspacePaneById( + node: WorkspaceNode, + paneId: string, +): WorkspacePaneNode | null { + if (node.kind === "pane") { + return node.id === paneId ? node : null; + } + + return getWorkspacePaneById(node.children[0], paneId) ?? getWorkspacePaneById(node.children[1], paneId); +} + +/** + * Collects all node IDs in the subtree rooted at `node` using a left-to-right traversal. + * + * @returns An array of all node IDs (both pane and split) in traversal order (parent before children for splits, left subtree then right subtree). + */ +function listWorkspaceNodeIds(node: WorkspaceNode): string[] { + if (node.kind === "pane") { + return [node.id]; + } + + return [node.id, ...listWorkspaceNodeIds(node.children[0]), ...listWorkspaceNodeIds(node.children[1])]; +} + +/** + * Ensure a panel size is a positive finite number. + * + * @param value - The size value to validate and clamp + * @returns `0` if `value` is not finite or is less than or equal to 0, otherwise returns `value` + */ +function clampPanelSize(value: number): number { + if (!Number.isFinite(value) || value <= 0) { + return 0; + } + + return value; +} + +/** + * Convert a pair of raw panel sizes into normalized percentage sizes that sum to 100. + * + * Missing or invalid entries default to the module's DEFAULT_SIZES. If the provided sizes sum to + * zero or less after clamping, the default sizes are returned. Returned values are percentages + * for the left and right panels rounded to one decimal place. + * + * @param sizes - A two-element tuple or array representing raw sizes for left and right panels + * @returns The normalized `[leftPercent, rightPercent]` pair, each rounded to one decimal place + */ +export function normalizeSplitSizes(sizes: [number, number] | number[]): [number, number] { + const left = clampPanelSize(sizes[0] ?? DEFAULT_SIZES[0]); + const right = clampPanelSize(sizes[1] ?? DEFAULT_SIZES[1]); + const total = left + right; + + if (total <= 0) { + return [...DEFAULT_SIZES]; + } + + return [Math.round((left / total) * 1000) / 10, Math.round((right / total) * 1000) / 10]; +} + +/** + * Ensure a value yields a valid workspace split axis. + * + * @param value - Input to normalize; only the exact string `"vertical"` is preserved + * @returns `'vertical'` if `value` strictly equals `"vertical"`, `'horizontal'` otherwise + */ +function normalizeSplitAxis(value: unknown): WorkspaceSplitAxis { + return value === "vertical" ? "vertical" : "horizontal"; +} + +/** + * Replace a pane node with the given `paneId` in a workspace subtree by applying an updater function. + * + * @param node - The workspace node to search and update + * @param paneId - The id of the pane to replace + * @param update - Function that receives the matching `WorkspacePaneNode` and returns the replacement `WorkspaceNode` + * @returns The updated workspace node subtree; returns the original subtree if no pane with `paneId` is found + */ +function replacePaneNode( + node: WorkspaceNode, + paneId: string, + update: (pane: WorkspacePaneNode) => WorkspaceNode, +): WorkspaceNode { + if (node.kind === "pane") { + return node.id === paneId ? update(node) : node; + } + + return { + ...node, + children: [ + replacePaneNode(node.children[0], paneId, update), + replacePaneNode(node.children[1], paneId, update), + ], + }; +} + +/** + * Split the specified pane into two along the given axis and focus the newly created pane. + * + * @param state - The current workspace state + * @param paneId - ID of the pane to split + * @param axis - Split orientation (`"horizontal"` or `"vertical"`) + * @returns The updated workspace state where the target pane is replaced by a split node containing the original pane and a new empty pane, the focus is set to the new pane, and pane/split counters are incremented + */ +export function splitWorkspacePane( + state: WorkspaceState, + paneId: string, + axis: WorkspaceSplitAxis, +): WorkspaceState { + if (!getWorkspacePaneById(state.root, paneId)) { + return state; + } + + const nextPane = createPane(null, state.nextPaneNumber); + const nextSplitId = createSplitId(state.nextSplitNumber); + + return { + root: replacePaneNode(state.root, paneId, (pane) => ({ + kind: "split", + id: nextSplitId, + axis, + sizes: [...DEFAULT_SIZES], + children: [pane, nextPane], + })), + focusedPaneId: nextPane.id, + nextPaneNumber: state.nextPaneNumber + 1, + nextSplitNumber: state.nextSplitNumber + 1, + }; +} + +type ClosePaneResult = { + node: WorkspaceNode; + removed: boolean; +}; + +/** + * Removes the pane with the specified `paneId` from the given subtree, collapsing its parent split when the sibling replaces the removed pane. + * + * @param node - The subtree to operate on (a pane or split node) + * @param paneId - The id of the pane to remove + * @returns A `ClosePaneResult` containing the updated subtree as `node` and `removed` set to `true` if a pane was removed, `false` otherwise + */ +function closePaneFromNode(node: WorkspaceNode, paneId: string): ClosePaneResult { + if (node.kind === "pane") { + return { node, removed: false }; + } + + const [left, right] = node.children; + if (left.kind === "pane" && left.id === paneId) { + return { node: right, removed: true }; + } + + if (right.kind === "pane" && right.id === paneId) { + return { node: left, removed: true }; + } + + const leftResult = closePaneFromNode(left, paneId); + if (leftResult.removed) { + return { + removed: true, + node: { + ...node, + children: [leftResult.node, right], + }, + }; + } + + const rightResult = closePaneFromNode(right, paneId); + if (rightResult.removed) { + return { + removed: true, + node: { + ...node, + children: [left, rightResult.node], + }, + }; + } + + return { node, removed: false }; +} + +/** + * Get the first pane ID found in the given subtree. + * + * @param node - Root workspace node to search + * @returns The first pane's `id` in traversal order, or an empty string if no pane exists + */ +function getFirstPaneId(node: WorkspaceNode): string { + return listWorkspacePanes(node)[0]?.id ?? ""; +} + +/** + * Remove a pane from the workspace tree, collapsing any redundant split node and updating focus. + * + * @param state - The current workspace state + * @param paneId - The id of the pane to remove + * @returns The updated workspace state with the specified pane removed and the focused pane adjusted when necessary; if the pane was not removed (because it doesn't exist or the workspace contains only one pane), returns the original `state` + */ +export function closeWorkspacePane(state: WorkspaceState, paneId: string): WorkspaceState { + const panes = listWorkspacePanes(state.root); + if (panes.length <= 1) { + return state; + } + + const result = closePaneFromNode(state.root, paneId); + if (!result.removed) { + return state; + } + + const nextFocusedPaneId = + state.focusedPaneId === paneId ? getFirstPaneId(result.node) : state.focusedPaneId; + + return { + ...state, + root: result.node, + focusedPaneId: nextFocusedPaneId, + }; +} + +/** + * Set focus to the pane with the given pane id if that pane exists in the workspace. + * + * @param state - The current workspace state + * @param paneId - The id of the pane to focus + * @returns The updated workspace state with `focusedPaneId` set to `paneId` if the pane exists, otherwise the original state unchanged + */ +export function focusWorkspacePane(state: WorkspaceState, paneId: string): WorkspaceState { + return getWorkspacePaneById(state.root, paneId) + ? { ...state, focusedPaneId: paneId } + : state; +} + +/** + * Set the project assignment for a specific pane in the workspace state. + * + * @param paneId - The id of the pane to update + * @param projectId - The project id to assign to the pane, or `null` to clear it + * @returns The workspace state with the pane's `projectId` updated; returns the original state unchanged if the pane was not found + */ +export function assignProjectToPane( + state: WorkspaceState, + paneId: string, + projectId: string | null, +): WorkspaceState { + if (!getWorkspacePaneById(state.root, paneId)) { + return state; + } + + return { + ...state, + root: replacePaneNode(state.root, paneId, (pane) => ({ + ...pane, + projectId, + })), + }; +} + +/** + * Recursively traverses a workspace node tree and applies `update` to the split node with `id === splitId`. + * + * @param node - The current workspace node to process (pane or split) + * @param splitId - The identifier of the split node to replace + * @param update - A function that receives the matching `WorkspaceSplitNode` and returns its replacement + * @returns The resulting `WorkspaceNode` with the matching split replaced; if no matching split is found, returns the original subtree unchanged + */ +function replaceSplitNode( + node: WorkspaceNode, + splitId: string, + update: (split: WorkspaceSplitNode) => WorkspaceSplitNode, +): WorkspaceNode { + if (node.kind === "pane") { + return node; + } + + if (node.id === splitId) { + return update(node); + } + + return { + ...node, + children: [ + replaceSplitNode(node.children[0], splitId, update), + replaceSplitNode(node.children[1], splitId, update), + ], + }; +} + +/** + * Update the sizes for a split node in the workspace tree. + * + * @param state - The current workspace state + * @param splitId - The id of the split node to update + * @param sizes - Two numeric proportions (or an array of numbers) for the split; values will be normalized and clamped + * @returns The updated WorkspaceState with the specified split node's `sizes` replaced by normalized values + */ +export function updateSplitSizes( + state: WorkspaceState, + splitId: string, + sizes: [number, number] | number[], +): WorkspaceState { + return { + ...state, + root: replaceSplitNode(state.root, splitId, (split) => ({ + ...split, + sizes: normalizeSplitSizes(sizes), + })), + }; +} + +/** + * Populate a map with normalized rectangles for every pane in the given subtree. + * + * @param node - The workspace node to traverse (pane or split). + * @param rect - The bounding rectangle for `node` using normalized coordinates `{ x, y, width, height }`. + * @param byPaneId - Map to populate with entries mapping each pane's `id` to its computed rectangle. + */ +function collectPaneRects( + node: WorkspaceNode, + rect: WorkspaceRect, + byPaneId: Map, +): void { + if (node.kind === "pane") { + byPaneId.set(node.id, rect); + return; + } + + const [leftSize] = normalizeSplitSizes(node.sizes); + if (node.axis === "horizontal") { + const leftWidth = rect.width * (leftSize / 100); + collectPaneRects(node.children[0], { ...rect, width: leftWidth }, byPaneId); + collectPaneRects( + node.children[1], + { + ...rect, + x: rect.x + leftWidth, + width: rect.width - leftWidth, + }, + byPaneId, + ); + return; + } + + const topHeight = rect.height * (leftSize / 100); + collectPaneRects(node.children[0], { ...rect, height: topHeight }, byPaneId); + collectPaneRects( + node.children[1], + { + ...rect, + y: rect.y + topHeight, + height: rect.height - topHeight, + }, + byPaneId, + ); +} + +/** + * Computes normalized layout rectangles for every pane in the workspace tree. + * + * Traverses `node` and assigns each pane a `WorkspaceRect` with `x`, `y`, `width`, and `height` normalized to the unit rectangle [0,1]×[0,1]. + * + * @param node - Root workspace node to compute pane rectangles from + * @returns A map from pane id to its normalized `WorkspaceRect` + */ +function getPaneRects(node: WorkspaceNode): Map { + const byPaneId = new Map(); + collectPaneRects( + node, + { + x: 0, + y: 0, + width: 1, + height: 1, + }, + byPaneId, + ); + return byPaneId; +} + +/** + * Compute the length of the intersection between two one-dimensional intervals. + * + * @param startA - Start coordinate of the first interval (order relative to `endA` is not required) + * @param endA - End coordinate of the first interval (order relative to `startA` is not required) + * @param startB - Start coordinate of the second interval (order relative to `endB` is not required) + * @param endB - End coordinate of the second interval (order relative to `startB` is not required) + * @returns The overlapping length of the two intervals, or `0` if they do not overlap + */ +function overlapAmount(startA: number, endA: number, startB: number, endB: number): number { + return Math.max(0, Math.min(endA, endB) - Math.max(startA, startB)); +} + +/** + * Move the workspace focus to the best candidate pane in the specified direction. + * + * Searches panes spatially and selects the adjacent pane in `direction` using + * a prioritized heuristic: smallest gap in the movement axis, then largest + * overlap on the orthogonal axis, then smallest center-to-center distance. + * + * @param state - The current workspace state + * @param direction - Direction to move focus (`left`, `right`, `up`, or `down`) + * @returns A workspace state with `focusedPaneId` set to the chosen adjacent pane, or the original `state` if no suitable pane exists + */ +export function moveWorkspaceFocus( + state: WorkspaceState, + direction: WorkspaceMoveDirection, +): WorkspaceState { + const rects = getPaneRects(state.root); + const currentRect = rects.get(state.focusedPaneId); + if (!currentRect) { + return state; + } + + let nextPaneId: string | null = null; + let bestGap = Number.POSITIVE_INFINITY; + let bestOverlap = Number.NEGATIVE_INFINITY; + let bestDistance = Number.POSITIVE_INFINITY; + + for (const [paneId, rect] of rects) { + if (paneId === state.focusedPaneId) { + continue; + } + + let gap = Number.POSITIVE_INFINITY; + let overlap = 0; + let distance = Number.POSITIVE_INFINITY; + + if (direction === "left") { + overlap = overlapAmount(currentRect.y, currentRect.y + currentRect.height, rect.y, rect.y + rect.height); + gap = currentRect.x - (rect.x + rect.width); + distance = Math.abs(currentRect.y + currentRect.height / 2 - (rect.y + rect.height / 2)); + } else if (direction === "right") { + overlap = overlapAmount(currentRect.y, currentRect.y + currentRect.height, rect.y, rect.y + rect.height); + gap = rect.x - (currentRect.x + currentRect.width); + distance = Math.abs(currentRect.y + currentRect.height / 2 - (rect.y + rect.height / 2)); + } else if (direction === "up") { + overlap = overlapAmount(currentRect.x, currentRect.x + currentRect.width, rect.x, rect.x + rect.width); + gap = currentRect.y - (rect.y + rect.height); + distance = Math.abs(currentRect.x + currentRect.width / 2 - (rect.x + rect.width / 2)); + } else { + overlap = overlapAmount(currentRect.x, currentRect.x + currentRect.width, rect.x, rect.x + rect.width); + gap = rect.y - (currentRect.y + currentRect.height); + distance = Math.abs(currentRect.x + currentRect.width / 2 - (rect.x + rect.width / 2)); + } + + if (gap < -0.0001 || overlap <= 0) { + continue; + } + + if ( + gap < bestGap - 0.0001 || + (Math.abs(gap - bestGap) <= 0.0001 && overlap > bestOverlap + 0.0001) || + (Math.abs(gap - bestGap) <= 0.0001 && + Math.abs(overlap - bestOverlap) <= 0.0001 && + distance < bestDistance) + ) { + nextPaneId = paneId; + bestGap = gap; + bestOverlap = overlap; + bestDistance = distance; + } + } + + return nextPaneId ? { ...state, focusedPaneId: nextPaneId } : state; +} + +/** + * Advance focus to the next pane in traversal order, wrapping to the first pane. + * + * @returns The workspace state with `focusedPaneId` set to the next pane in traversal order; if the current focused pane is missing, sets focus to the first pane; returns the original state unchanged when the workspace has one or zero panes. + */ +export function focusNextWorkspacePane(state: WorkspaceState): WorkspaceState { + const panes = listWorkspacePanes(state.root); + if (panes.length <= 1) { + return state; + } + + const currentIndex = panes.findIndex((pane) => pane.id === state.focusedPaneId); + if (currentIndex === -1) { + return { + ...state, + focusedPaneId: panes[0]?.id ?? state.focusedPaneId, + }; + } + + const nextIndex = (currentIndex + 1) % panes.length; + return { + ...state, + focusedPaneId: panes[nextIndex]?.id ?? state.focusedPaneId, + }; +} + +/** + * Return a sanitized copy of a workspace node tree where pane `projectId`s are restricted to a given set and split nodes are normalized. + * + * @param node - The workspace node subtree to sanitize + * @param projectIds - Set of allowed project IDs; pane `projectId` values not contained in this set will be replaced with `null` + * @returns A new `WorkspaceNode` with pane `projectId`s filtered, split `axis` and `sizes` normalized, and children recursively processed + */ +function collectProjectIds(node: WorkspaceNode, projectIds: Set): WorkspaceNode { + if (node.kind === "pane") { + return { + ...node, + projectId: node.projectId && projectIds.has(node.projectId) ? node.projectId : null, + }; + } + + return { + ...node, + axis: normalizeSplitAxis(node.axis), + sizes: normalizeSplitSizes(node.sizes), + children: [ + collectProjectIds(node.children[0], projectIds), + collectProjectIds(node.children[1], projectIds), + ], + }; +} + +/** + * Compute the next numeric suffix for node IDs using the given prefix. + * + * @param node - The workspace subtree to scan for existing node IDs + * @param prefix - The ID prefix to search for (`"pane"` or `"split"`) + * @returns The next integer to use for an ID of the form `-`, i.e. one greater than the highest numeric suffix found (returns `1` if no matching IDs exist) + */ +function deriveNextNumber(node: WorkspaceNode, prefix: "pane" | "split"): number { + let max = 0; + for (const id of listWorkspaceNodeIds(node)) { + const match = id.match(new RegExp(`^${prefix}-(\\d+)$`)); + if (!match) { + continue; + } + + const value = Number(match[1]); + if (Number.isInteger(value)) { + max = Math.max(max, value); + } + } + + return max + 1; +} + +type RawWorkspaceNode = { + kind?: unknown; + id?: unknown; + projectId?: unknown; + axis?: unknown; + sizes?: unknown; + children?: unknown; +}; + +/** + * Parse an unknown value into a sanitized WorkspaceNode or return `null` if invalid. + * + * Attempts to interpret `rawNode` as either a `pane` or a `split`. For pane nodes, trims and + * accepts string `id` and `projectId`, substituting a generated pane id or `null` project when + * missing or empty. For split nodes, parses the first two children recursively and: + * - returns `null` if neither child is valid, + * - returns the single valid child if the other is absent (eliminating the split wrapper), + * - otherwise returns a split node with a normalized `axis`, normalized `sizes`, and a generated + * split id when necessary. + * + * @param rawNode - Raw input (e.g., parsed JSON) representing a workspace node + * @returns A sanitized `WorkspaceNode` when `rawNode` represents a valid pane or split, or `null` when it cannot be parsed + */ +function parseWorkspaceNode(rawNode: unknown): WorkspaceNode | null { + if (!rawNode || typeof rawNode !== "object") { + return null; + } + + const node = rawNode as RawWorkspaceNode; + if (node.kind === "pane") { + return { + kind: "pane", + id: typeof node.id === "string" && node.id.trim() ? node.id : createPaneId(1), + projectId: typeof node.projectId === "string" && node.projectId.trim() ? node.projectId : null, + }; + } + + if (node.kind === "split" && Array.isArray(node.children) && node.children.length >= 2) { + const left = parseWorkspaceNode(node.children[0]); + const right = parseWorkspaceNode(node.children[1]); + + if (!left && !right) { + return null; + } + + if (!left) { + return right; + } + + if (!right) { + return left; + } + + return { + kind: "split", + id: typeof node.id === "string" && node.id.trim() ? node.id : createSplitId(1), + axis: normalizeSplitAxis(node.axis), + sizes: normalizeSplitSizes(Array.isArray(node.sizes) ? node.sizes : DEFAULT_SIZES), + children: [left, right], + }; + } + + return null; +} + +/** + * Get the first pane's projectId found in the subtree rooted at `node`, or `null` if none exists. + * + * @param node - Root workspace node to search for pane project IDs + * @returns The first `projectId` string encountered among panes, or `null` when no pane has a projectId + */ +function getFallbackPaneProjectId(node: WorkspaceNode): string | null { + return listWorkspacePanes(node).find((pane) => pane.projectId)?.projectId ?? null; +} + +/** + * Convert and sanitize an arbitrary value into a valid WorkspaceState. + * + * Parses a raw, potentially malformed workspace representation, repairs or replaces the root tree as needed, + * strips pane `projectId`s that are not in `validProjectIds`, ensures the focused pane exists (or selects a fallback), + * and computes valid `nextPaneNumber` and `nextSplitNumber` counters. If the input is not an object a fresh workspace + * is created. If `fallbackProjectId` is provided and no pane in the sanitized tree has an allowed project, the + * fallback project is assigned to the focused pane. + * + * @param rawState - The untrusted input to normalize (may be any value). + * @param validProjectIds - Set of allowed project IDs; pane `projectId`s not in this set are removed (set to `null`). + * @param fallbackProjectId - Optional project ID to assign to the focused pane when no pane has an allowed project. + * @returns A well-formed WorkspaceState with a sanitized root, a valid `focusedPaneId`, and derived `next*Number` counters. + */ +export function normalizeWorkspaceState( + rawState: unknown, + validProjectIds: Set, + fallbackProjectId: string | null = null, +): WorkspaceState { + if (!rawState || typeof rawState !== "object") { + return createWorkspaceState(fallbackProjectId); + } + + const candidate = rawState as Partial; + const parsedRoot = parseWorkspaceNode(candidate.root); + const sanitizedRoot = collectProjectIds( + parsedRoot ?? createWorkspaceState(fallbackProjectId).root, + validProjectIds, + ); + + const panes = listWorkspacePanes(sanitizedRoot); + const nextFocusedPaneId = + typeof candidate.focusedPaneId === "string" && + panes.some((pane) => pane.id === candidate.focusedPaneId) + ? candidate.focusedPaneId + : panes[0]?.id ?? createPaneId(1); + + const nextState: WorkspaceState = { + root: sanitizedRoot, + focusedPaneId: nextFocusedPaneId, + nextPaneNumber: + typeof candidate.nextPaneNumber === "number" && candidate.nextPaneNumber > 0 + ? candidate.nextPaneNumber + : deriveNextNumber(sanitizedRoot, "pane"), + nextSplitNumber: + typeof candidate.nextSplitNumber === "number" && candidate.nextSplitNumber > 0 + ? candidate.nextSplitNumber + : deriveNextNumber(sanitizedRoot, "split"), + }; + + if (fallbackProjectId && !validProjectIds.has(getFallbackPaneProjectId(nextState.root) ?? "")) { + return assignProjectToPane(nextState, nextState.focusedPaneId, fallbackProjectId); + } + + return nextState; +} + +/** + * Serializes a workspace state to a JSON string. + * + * @param state - The workspace state to serialize + * @returns A JSON string representation of `state` + */ +export function serializeWorkspaceState(state: WorkspaceState): string { + return JSON.stringify(state); +} diff --git a/src/components/ui/resizable.tsx b/src/components/ui/resizable.tsx new file mode 100644 index 0000000..3e75b20 --- /dev/null +++ b/src/components/ui/resizable.tsx @@ -0,0 +1,67 @@ +import * as React from "react"; +import { + Group, + Panel, + Separator, + type GroupProps, +} from "react-resizable-panels"; +import { cn } from "@/lib/utils"; + +/** + * Renders a configured `Group` that fills its container and switches to column layout when vertical. + * + * @param className - Additional class names to merge with the component's default layout classes + * @param orientation - Layout orientation; when `"vertical"`, the group uses column layout + * @returns The configured `Group` element with merged class names and forwarded props + */ +export function ResizablePanelGroup({ className, orientation, ...props }: GroupProps) { + return ( + + ); +} + +export { Panel as ResizablePanel }; + +type ResizableHandleProps = React.ComponentProps & { + withHandle?: boolean; +}; + +/** + * Renders a styled separator used as a resizable handle. + * + * When `withHandle` is true, an inner handle indicator is rendered; all other props + * are forwarded to the underlying `Separator` element. + * + * @param className - Additional class names to merge onto the separator's root element. + * @param withHandle - Whether to render the inner handle indicator. Defaults to `false`. + * @returns A React element representing the resizable separator handle. + */ +export function ResizableHandle({ + className, + withHandle = false, + ...props +}: ResizableHandleProps) { + return ( + + {withHandle ? ( +
+
+
+ ) : null} + + ); +} diff --git a/src/components/ui/sidebar.tsx b/src/components/ui/sidebar.tsx index 51419c1..96eeca0 100644 --- a/src/components/ui/sidebar.tsx +++ b/src/components/ui/sidebar.tsx @@ -28,7 +28,8 @@ const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7 const SIDEBAR_WIDTH = "16rem" const SIDEBAR_WIDTH_MOBILE = "18rem" const SIDEBAR_WIDTH_ICON = "3rem" -const SIDEBAR_KEYBOARD_SHORTCUT = "b" +const SIDEBAR_KEYBOARD_SHORTCUT = "t" +export const SIDEBAR_KEYBOARD_SHORTCUT_LABEL = "Ctrl+T" export const SIDEBAR_TOGGLE_EVENT = "dextop:sidebar-toggle" type SidebarContextProps = { @@ -52,6 +53,17 @@ function useSidebar() { return context } +/** + * Provides sidebar state and controls to descendants and renders the sidebar wrapper. + * + * Manages desktop open/closed state (supporting controlled `open`/`onOpenChange`), persists the desktop state in a cookie, maintains mobile sheet visibility, registers the Ctrl+T keyboard shortcut and a custom window toggle event, and exposes state and control callbacks via SidebarContext. Also sets CSS variables for sidebar widths and wraps children in a TooltipProvider. + * + * @param defaultOpen - Initial desktop open state when the component is uncontrolled (defaults to `true`). + * @param open - Controlled desktop open state. When provided, component becomes controlled and internal state is not used. + * @param onOpenChange - Callback invoked with the next desktop open state when it changes (used for controlled updates). If not provided, the provider updates its internal state and writes the state to a cookie. + * + * @returns The SidebarContext provider and wrapper element that renders children with sidebar-related layout and tooling. + */ function SidebarProvider({ defaultOpen = true, open: openProp, @@ -96,8 +108,8 @@ function SidebarProvider({ React.useEffect(() => { const handleKeyDown = (event: KeyboardEvent) => { if ( - event.key === SIDEBAR_KEYBOARD_SHORTCUT && - (event.metaKey || event.ctrlKey) + event.key.toLowerCase() === SIDEBAR_KEYBOARD_SHORTCUT && + event.ctrlKey ) { event.preventDefault() toggleSidebar() diff --git a/src/contexts/projects-context.tsx b/src/contexts/projects-context.tsx index 35c1c5c..c16ae8e 100644 --- a/src/contexts/projects-context.tsx +++ b/src/contexts/projects-context.tsx @@ -23,7 +23,7 @@ type ProjectsContextValue = { isProjectsInitialized: boolean; selectedProjectId: string | null; selectedProjectName: string; - selectProject: (projectId: string) => void; + selectProject: (projectId: string | null) => void; setProjectTaskCount: (projectPath: string, taskCount: number) => void; reloadProjects: () => Promise; openProject: () => Promise; @@ -36,6 +36,16 @@ type ProjectsContextValue = { const ProjectsContext = createContext(undefined); +/** + * Provides projects state, selection, and related actions to descendant components. + * + * The provider exposes the current projects list, initialization status, selected project id and name, + * and functions to select projects, update task counts, reload/modify the project list, and manage + * the underlying mutation subscription lifecycle. + * + * @param children - The React children that will have access to the Projects context + * @returns The ProjectsContext provider element wrapping `children` + */ export function ProjectsProvider({ children }: { children: ReactNode }) { const [projects, setProjects] = useState([]); const [isProjectsInitialized, setIsProjectsInitialized] = useState(false); @@ -73,7 +83,7 @@ export function ProjectsProvider({ children }: { children: ReactNode }) { }); }, [ensureSelection]); - const selectProject = useCallback((projectId: string) => { + const selectProject = useCallback((projectId: string | null) => { setSelectedProjectId(projectId); }, []); diff --git a/src/contexts/tasks-context.tsx b/src/contexts/tasks-context.tsx index 5b607fb..9ebea9a 100644 --- a/src/contexts/tasks-context.tsx +++ b/src/contexts/tasks-context.tsx @@ -18,9 +18,9 @@ import { } from "@/lib/tasks-service"; type TasksContextValue = { - projectTasks: DexTask[]; + projectTasksByPath: Record; + getProjectTasks: (projectPath: string | null) => DexTask[]; initializeTasksStore: () => Promise; - setActiveProjectPath: (projectPath: string | null) => Promise; disposeTasksStore: () => void; }; @@ -30,28 +30,29 @@ function countOpenTasks(tasks: DexTask[]): number { return tasks.filter((task) => !task.completed).length; } +/** + * Provides a React context that manages per-project task state, watches live task updates, and exposes helpers to initialize and dispose the task store. + * + * The provider keeps an in-memory map of tasks keyed by project path, synchronizes per-project watchers with the active project list, updates open-task counts, and supplies `getProjectTasks`, `initializeTasksStore`, and `disposeTasksStore` to descendants via context. + * + * @returns The context provider element that supplies the task store and control functions to its children + */ export function TasksProvider({ children }: { children: ReactNode }) { const { projects, setProjectTaskCount } = useProjects(); - const [projectTasks, setProjectTasks] = useState([]); - const [activeProjectPath, setActiveProjectPathState] = useState(null); + const [projectTasksByPath, setProjectTasksByPath] = useState>({}); const [isInitialized, setIsInitialized] = useState(false); const unlistenTaskUpdatesRef = useRef<(() => void) | undefined>(undefined); const initializationPromiseRef = useRef | undefined>(undefined); const isInitializedRef = useRef(false); const watchedProjectPathsRef = useRef>(new Set()); - const activeProjectPathRef = useRef(null); - - useEffect(() => { - activeProjectPathRef.current = activeProjectPath; - }, [activeProjectPath]); const applyTasks = useCallback( (projectPath: string, tasks: DexTask[]) => { - if (activeProjectPathRef.current === projectPath) { - setProjectTasks(tasks); - } - + setProjectTasksByPath((currentTasks) => ({ + ...currentTasks, + [projectPath]: tasks, + })); setProjectTaskCount(projectPath, countOpenTasks(tasks)); }, [setProjectTaskCount], @@ -101,6 +102,15 @@ export function TasksProvider({ children }: { children: ReactNode }) { }); }, [isInitialized, projects, syncProjectWatches]); + useEffect(() => { + const projectPaths = new Set(projects.map((project) => project.path)); + setProjectTasksByPath((currentTasks) => + Object.fromEntries( + Object.entries(currentTasks).filter(([projectPath]) => projectPaths.has(projectPath)), + ), + ); + }, [projects]); + const initializeTasksStore = useCallback(async () => { if (isInitializedRef.current) { return; @@ -125,53 +135,38 @@ export function TasksProvider({ children }: { children: ReactNode }) { } }, [applyTasks]); - const setActiveProjectPath = useCallback( - async (projectPath: string | null) => { - if (projectPath === activeProjectPathRef.current) { - return; - } - - activeProjectPathRef.current = projectPath; - setActiveProjectPathState(projectPath); - - if (!projectPath) { - setProjectTasks([]); - return; - } - - const tasks = await watchProjectTasks(projectPath); - if (activeProjectPathRef.current !== projectPath) { - return; - } - - applyTasks(projectPath, tasks); - }, - [applyTasks], - ); - const disposeTasksStore = useCallback(() => { unlistenTaskUpdatesRef.current?.(); unlistenTaskUpdatesRef.current = undefined; initializationPromiseRef.current = undefined; isInitializedRef.current = false; watchedProjectPathsRef.current = new Set(); - activeProjectPathRef.current = null; setIsInitialized(false); void clearProjectTasksWatch().catch((error) => { console.error("Failed to clear project task watcher", error); }); - setProjectTasks([]); - setActiveProjectPathState(null); + setProjectTasksByPath({}); }, []); + const getProjectTasks = useCallback( + (projectPath: string | null) => { + if (!projectPath) { + return []; + } + + return projectTasksByPath[projectPath] ?? []; + }, + [projectTasksByPath], + ); + const value = useMemo( () => ({ - projectTasks, + projectTasksByPath, + getProjectTasks, initializeTasksStore, - setActiveProjectPath, disposeTasksStore, }), - [disposeTasksStore, initializeTasksStore, projectTasks, setActiveProjectPath], + [disposeTasksStore, getProjectTasks, initializeTasksStore, projectTasksByPath], ); return {children}; diff --git a/src/routes/projects.$projectId.tsx b/src/routes/projects.$projectId.tsx index 0d01f3b..6fd4530 100644 --- a/src/routes/projects.$projectId.tsx +++ b/src/routes/projects.$projectId.tsx @@ -1,210 +1,25 @@ -import { useEffect, useMemo, useState } from "react"; import { Navigate, createFileRoute } from "@tanstack/react-router"; -import { - BoardSummaryRail, - compareTasksByRecentActivity, - CreateTaskDialog, - EmptyProjectBoard, - filterBoardTasks, - groupSubtasksByParentId, - groupTasksByColumn, - indexTasksById, - KANBAN_COLUMNS, - KanbanColumn, - ProjectBoardHeader, - ProjectBoardPlaceholder, - summarizeBoardTasks, - summarizeSubtaskProgress, - TaskDetailsDialog, - type KanbanColumnKey, - type TaskFilterKey, -} from "@/components/project-board"; -import { useProjects } from "@/contexts/projects-context"; -import { useTasks } from "@/contexts/tasks-context"; export const Route = createFileRoute("/projects/$projectId")({ component: RouteComponent, }); +/** + * Redirects the current route to /projects while preserving existing query parameters and setting `projectId` from the current route params. + * + * @returns A React element that navigates to `/projects`, merging the current search params with `projectId` set to the route's `projectId`; the navigation replaces the current history entry. + */ function RouteComponent() { const params = Route.useParams(); - const { isProjectsInitialized, projects, selectProject } = useProjects(); - const { projectTasks } = useTasks(); - const [isCreateTaskOpen, setIsCreateTaskOpen] = useState(false); - const [isDetailsOpen, setIsDetailsOpen] = useState(false); - const [selectedTaskId, setSelectedTaskId] = useState(null); - const [taskFilter, setTaskFilter] = useState("all"); - const [collapsedColumns, setCollapsedColumns] = useState>({ - todo: false, - inProgress: false, - blocked: false, - done: false, - }); - - const routeProject = useMemo( - () => projects.find((project) => project.id === params.projectId) ?? null, - [params.projectId, projects], - ); - - const shouldRedirectToProjects = isProjectsInitialized && routeProject === null; - - useEffect(() => { - if (!routeProject) { - return; - } - - selectProject(routeProject.id); - }, [routeProject, selectProject]); - - const sortedProjectTasks = useMemo( - () => [...projectTasks].sort(compareTasksByRecentActivity), - [projectTasks], - ); - - const groupedTasks = useMemo(() => groupTasksByColumn(sortedProjectTasks), [sortedProjectTasks]); - - const summary = useMemo( - () => summarizeBoardTasks(sortedProjectTasks, groupedTasks), - [groupedTasks, sortedProjectTasks], - ); - - const filteredGroupedTasks = useMemo( - () => filterBoardTasks(groupedTasks, taskFilter), - [groupedTasks, taskFilter], - ); - - const taskById = useMemo(() => indexTasksById(sortedProjectTasks), [sortedProjectTasks]); - - const subtasksByParentId = useMemo( - () => groupSubtasksByParentId(sortedProjectTasks), - [sortedProjectTasks], - ); - - const subtaskProgressByTaskId = useMemo( - () => summarizeSubtaskProgress(subtasksByParentId), - [subtasksByParentId], - ); - - const selectedTask = useMemo(() => { - if (!selectedTaskId) { - return null; - } - - return sortedProjectTasks.find((task) => task.id === selectedTaskId) ?? null; - }, [selectedTaskId, sortedProjectTasks]); - - const selectedTaskParent = useMemo(() => { - if (!selectedTask?.parentId) { - return null; - } - - return taskById.get(selectedTask.parentId) ?? null; - }, [selectedTask, taskById]); - - const selectedTaskSubtasks = useMemo(() => { - if (!selectedTask) { - return []; - } - - return subtasksByParentId.get(selectedTask.id) ?? []; - }, [selectedTask, subtasksByParentId]); - - const taskRelationOptions = sortedProjectTasks; - - const getSubtaskProgress = (taskId: string) => subtaskProgressByTaskId.get(taskId); - - const openCreateTaskDialog = () => { - setIsCreateTaskOpen(true); - }; - - const openTaskDetails = (taskId: string) => { - setSelectedTaskId(taskId); - setIsDetailsOpen(true); - }; - - const handleDetailsOpenChange = (open: boolean) => { - setIsDetailsOpen(open); - if (!open) { - setSelectedTaskId(null); - } - }; - - const toggleColumnCollapsed = (columnKey: KanbanColumnKey) => { - setCollapsedColumns((current) => ({ - ...current, - [columnKey]: !current[columnKey], - })); - }; - - if (shouldRedirectToProjects) { - return ; - } return ( -
-
- {routeProject ? ( -
- - - {projectTasks.length > 0 ? ( - <> - -
- {KANBAN_COLUMNS.map((column) => { - const tasksInColumn = filteredGroupedTasks[column.key]; - return ( - toggleColumnCollapsed(column.key)} - tasks={tasksInColumn} - /> - ); - })} -
- - ) : ( - - )} -
- ) : ( - - )} -
- - - - -
+ ({ + ...current, + projectId: params.projectId, + })} + to="/projects" + /> ); } diff --git a/src/routes/projects.tsx b/src/routes/projects.tsx index 1382db0..fa8cbe6 100644 --- a/src/routes/projects.tsx +++ b/src/routes/projects.tsx @@ -1,25 +1,41 @@ -import { useEffect, useMemo } from "react"; -import { Outlet, createFileRoute } from "@tanstack/react-router"; -import { ProjectSidebar } from "@/components/project-sidebar"; -import { SidebarInset, SidebarProvider, SidebarTrigger } from "@/components/ui/sidebar"; -import { useProjects } from "@/contexts/projects-context"; +import { useEffect } from "react"; +import { createFileRoute, useNavigate } from "@tanstack/react-router"; +import { ProjectsWorkspace } from "@/components/projects-workspace"; import { useTasks } from "@/contexts/tasks-context"; +type ProjectsSearch = { + projectId?: string; + workspace?: string; +}; + export const Route = createFileRoute("/projects")({ + validateSearch: (search: Record): ProjectsSearch => ({ + projectId: + typeof search.projectId === "string" && search.projectId.trim() + ? search.projectId + : undefined, + workspace: + typeof search.workspace === "string" && search.workspace.trim() + ? search.workspace + : undefined, + }), component: RouteComponent, }); +/** + * Route component for "/projects" that renders the ProjectsWorkspace and synchronizes route search state. + * + * Initializes the tasks store on mount and disposes it on unmount. If a `projectId` is present in the validated + * route search, the component clears it from the URL (using a replace navigation). When the workspace state changes + * via `onWorkspaceStateChange`, the component updates the `workspace` search parameter (using replace) while preserving + * other search fields. + * + * @returns The rendered element for the `/projects` route. + */ function RouteComponent() { - const { projects, selectedProjectId, selectedProjectName } = useProjects(); - const { disposeTasksStore, initializeTasksStore, setActiveProjectPath } = useTasks(); - - const selectedProjectPath = useMemo(() => { - if (!selectedProjectId) { - return null; - } - - return projects.find((project) => project.id === selectedProjectId)?.path ?? null; - }, [projects, selectedProjectId]); + const navigate = useNavigate({ from: "/projects" }); + const search = Route.useSearch(); + const { disposeTasksStore, initializeTasksStore } = useTasks(); useEffect(() => { void initializeTasksStore().catch((error) => { @@ -32,26 +48,37 @@ function RouteComponent() { }, [disposeTasksStore, initializeTasksStore]); useEffect(() => { - void setActiveProjectPath(selectedProjectPath).catch((error) => { - console.error("Failed to watch project tasks", error); + if (!search.projectId) { + return; + } + + navigate({ + replace: true, + search: (current) => ({ + ...current, + projectId: undefined, + }), + to: "/projects", }); - }, [selectedProjectPath, setActiveProjectPath]); + }, [navigate, search.projectId]); return ( - - - -
- -
-

Project

-

- {selectedProjectName ?? "Choose a project"} -

-
-
- -
-
+ { + navigate({ + replace: true, + search: (current) => + current.workspace === serializedState + ? current + : { + ...current, + workspace: serializedState, + }, + to: "/projects", + }); + }} + routeWorkspaceState={search.workspace ?? null} + /> ); }