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
415 changes: 175 additions & 240 deletions bun.lock

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@
"react": "^19.1.1",
"react-dom": "^19.1.1",
"sonner": "^2.0.7",
"react-resizable-panels": "^4.7.4",
"tailwind-merge": "^3.5.0",
"tailwindcss": "^4.2.0"
},
Expand Down
5 changes: 5 additions & 0 deletions src/bun-test.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
declare module "bun:test" {
export const describe: (name: string, fn: () => void) => void;
export const test: (name: string, fn: () => void | Promise<void>) => void;
export const expect: (value: unknown) => any;
}
128 changes: 124 additions & 4 deletions src/components/command-palette/command-palette.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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;
Expand All @@ -41,7 +63,7 @@ type PaletteCommand = {
shortcutLabel?: string;
};

const GROUP_ORDER: CommandGroupKey[] = ["Projects", "Appearance", "Window"];
const GROUP_ORDER: CommandGroupKey[] = ["Projects", "Workspace", "Appearance", "Window"];

function toErrorMessage(error: unknown): string {
if (error instanceof Error && error.message) {
Expand All @@ -62,6 +84,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;
Expand Down Expand Up @@ -115,6 +138,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",
Expand All @@ -135,11 +254,12 @@ export function CommandPalette() {
},
disabled: !isSidebarAvailable,
keywords: ["sidebar", "panel", "navigation", "project"],
shortcutLabel: "Mod+B",
shortcutLabel: SIDEBAR_KEYBOARD_SHORTCUT_LABEL,
},
],
[
isSidebarAvailable,
isWorkspaceAvailable,
openProject,
openProjectInSeparateWindow,
resolvedTheme,
Expand Down
1 change: 1 addition & 0 deletions src/components/project-board/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,4 +16,5 @@ export { EmptyProjectBoard } from "./empty-project-board";
export { KanbanColumn } from "./kanban-column";
export { ProjectBoardHeader } from "./project-board-header";
export { ProjectBoardPlaceholder } from "./project-board-placeholder";
export { ProjectBoardView } from "./project-board-view";
export { TaskDetailsDialog } from "./task-details-dialog";
185 changes: 185 additions & 0 deletions src/components/project-board/project-board-view.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,185 @@
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[];
};

export function ProjectBoardView({ project, projectTasks }: ProjectBoardViewProps) {
const [isCreateTaskOpen, setIsCreateTaskOpen] = useState(false);
const [isDetailsOpen, setIsDetailsOpen] = useState(false);
const [selectedTaskId, setSelectedTaskId] = useState<string | null>(null);
const [taskFilter, setTaskFilter] = useState<TaskFilterKey>("all");
const [collapsedColumns, setCollapsedColumns] = useState<Record<KanbanColumnKey, boolean>>({
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 taskById.get(selectedTaskId) ?? null;
}, [selectedTaskId, taskById]);

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 (
<>
<section className="h-full overflow-hidden p-4 sm:p-5">
<div className="h-full">
<div className="flex h-full flex-col gap-4">
<ProjectBoardHeader
onAddTask={() => {
setIsCreateTaskOpen(true);
}}
openTasks={summary.open}
projectName={project.name}
projectPath={project.path}
totalTasks={summary.total}
/>

{projectTasks.length > 0 ? (
<>
<BoardSummaryRail
activeFilter={taskFilter}
blockedTasks={summary.blocked}
doneTasks={summary.done}
onFilterChange={setTaskFilter}
openTasks={summary.open}
totalTasks={summary.total}
/>
<div className="flex min-h-0 flex-1 gap-4 overflow-x-auto overflow-y-hidden pb-2">
{KANBAN_COLUMNS.map((column) => (
<KanbanColumn
key={column.key}
column={column}
getSubtaskProgress={getSubtaskProgress}
isCollapsed={collapsedColumns[column.key]}
onOpenTask={openTaskDetails}
onToggleCollapsed={() => toggleColumnCollapsed(column.key)}
tasks={filteredGroupedTasks[column.key]}
/>
))}
</div>
</>
) : (
<EmptyProjectBoard
onAddTask={() => {
setIsCreateTaskOpen(true);
}}
projectName={project.name}
/>
)}
</div>
</div>
</section>

<CreateTaskDialog
open={isCreateTaskOpen}
projectName={project.name}
projectPath={project.path}
relationOptions={taskRelationOptions}
setOpen={setIsCreateTaskOpen}
/>

<TaskDetailsDialog
onOpenChange={handleDetailsOpenChange}
onOpenTask={openTaskDetails}
open={isDetailsOpen}
selectedTask={selectedTask}
selectedTaskParent={selectedTaskParent}
selectedTaskSubtasks={selectedTaskSubtasks}
/>
</>
);
}
Loading
Loading