diff --git a/apps/server/src/routes/common.ts b/apps/server/src/routes/common.ts index d1308b30..0c781b45 100644 --- a/apps/server/src/routes/common.ts +++ b/apps/server/src/routes/common.ts @@ -29,6 +29,7 @@ const BINARY_EXTENSIONS = new Set([ ]); // Status map for git status codes +// Git porcelain format uses XY where X=staging area, Y=working tree const GIT_STATUS_MAP: Record = { M: "Modified", A: "Added", @@ -37,8 +38,42 @@ const GIT_STATUS_MAP: Record = { C: "Copied", U: "Updated", "?": "Untracked", + "!": "Ignored", + " ": "Unmodified", }; +/** + * Get a readable status text from git status codes + * Handles both single character and XY format status codes + */ +function getStatusText(indexStatus: string, workTreeStatus: string): string { + // Untracked files + if (indexStatus === "?" && workTreeStatus === "?") { + return "Untracked"; + } + + // Ignored files + if (indexStatus === "!" && workTreeStatus === "!") { + return "Ignored"; + } + + // Prioritize staging area status, then working tree + const primaryStatus = indexStatus !== " " && indexStatus !== "?" ? indexStatus : workTreeStatus; + + // Handle combined statuses + if (indexStatus !== " " && indexStatus !== "?" && workTreeStatus !== " " && workTreeStatus !== "?") { + // Both staging and working tree have changes + const indexText = GIT_STATUS_MAP[indexStatus] || "Changed"; + const workText = GIT_STATUS_MAP[workTreeStatus] || "Changed"; + if (indexText === workText) { + return indexText; + } + return `${indexText} (staged), ${workText} (unstaged)`; + } + + return GIT_STATUS_MAP[primaryStatus] || "Changed"; +} + /** * File status interface for git status results */ @@ -70,18 +105,46 @@ export async function isGitRepo(repoPath: string): Promise { /** * Parse the output of `git status --porcelain` into FileStatus array + * Git porcelain format: XY PATH where X=staging area status, Y=working tree status + * For renamed files: XY ORIG_PATH -> NEW_PATH */ export function parseGitStatus(statusOutput: string): FileStatus[] { return statusOutput .split("\n") .filter(Boolean) .map((line) => { - const statusChar = line[0]; - const filePath = line.slice(3); + // Git porcelain format uses two status characters: XY + // X = status in staging area (index) + // Y = status in working tree + const indexStatus = line[0] || " "; + const workTreeStatus = line[1] || " "; + + // File path starts at position 3 (after "XY ") + let filePath = line.slice(3); + + // Handle renamed files (format: "R old_path -> new_path") + if (indexStatus === "R" || workTreeStatus === "R") { + const arrowIndex = filePath.indexOf(" -> "); + if (arrowIndex !== -1) { + filePath = filePath.slice(arrowIndex + 4); // Use new path + } + } + + // Determine the primary status character for backwards compatibility + // Prioritize staging area status, then working tree + let primaryStatus: string; + if (indexStatus === "?" && workTreeStatus === "?") { + primaryStatus = "?"; // Untracked + } else if (indexStatus !== " " && indexStatus !== "?") { + primaryStatus = indexStatus; // Staged change + } else { + primaryStatus = workTreeStatus; // Working tree change + } + return { - status: statusChar, + status: primaryStatus, path: filePath, - statusText: GIT_STATUS_MAP[statusChar] || "Unknown", + statusText: getStatusText(indexStatus, workTreeStatus), }; }); } diff --git a/apps/server/src/routes/worktree/common.ts b/apps/server/src/routes/worktree/common.ts index 0b2446fc..b165b025 100644 --- a/apps/server/src/routes/worktree/common.ts +++ b/apps/server/src/routes/worktree/common.ts @@ -5,13 +5,17 @@ import { createLogger } from "../../lib/logger.js"; import { exec } from "child_process"; import { promisify } from "util"; +import path from "path"; +import fs from "fs/promises"; import { getErrorMessage as getErrorMessageShared, createLogError, } from "../common.js"; +import { FeatureLoader } from "../../services/feature-loader.js"; const logger = createLogger("Worktree"); const execAsync = promisify(exec); +const featureLoader = new FeatureLoader(); /** * Normalize path separators to forward slashes for cross-platform consistency. diff --git a/apps/ui/src/components/views/board-view/components/kanban-column.tsx b/apps/ui/src/components/views/board-view/components/kanban-column.tsx index 9c547b6c..9361e5e5 100644 --- a/apps/ui/src/components/views/board-view/components/kanban-column.tsx +++ b/apps/ui/src/components/views/board-view/components/kanban-column.tsx @@ -14,6 +14,8 @@ interface KanbanColumnProps { opacity?: number; showBorder?: boolean; hideScrollbar?: boolean; + /** Custom width in pixels. If not provided, defaults to 288px (w-72) */ + width?: number; } export const KanbanColumn = memo(function KanbanColumn({ @@ -26,17 +28,23 @@ export const KanbanColumn = memo(function KanbanColumn({ opacity = 100, showBorder = true, hideScrollbar = false, + width, }: KanbanColumnProps) { const { setNodeRef, isOver } = useDroppable({ id }); + // Use inline style for width if provided, otherwise use default w-72 + const widthStyle = width ? { width: `${width}px`, flexShrink: 0 } : undefined; + return (
{/* Background layer with opacity */} diff --git a/apps/ui/src/components/views/board-view/kanban-board.tsx b/apps/ui/src/components/views/board-view/kanban-board.tsx index 743f9f2e..1ebbf042 100644 --- a/apps/ui/src/components/views/board-view/kanban-board.tsx +++ b/apps/ui/src/components/views/board-view/kanban-board.tsx @@ -15,6 +15,7 @@ import { KanbanColumn, KanbanCard } from "./components"; import { Feature } from "@/store/app-store"; import { FastForward, Lightbulb, Archive } from "lucide-react"; import { useKeyboardShortcutsConfig } from "@/hooks/use-keyboard-shortcuts"; +import { useResponsiveKanban } from "@/hooks/use-responsive-kanban"; import { COLUMNS, ColumnId } from "./constants"; interface KanbanBoardProps { @@ -88,6 +89,9 @@ export function KanbanBoard({ suggestionsCount, onArchiveAllVerified, }: KanbanBoardProps) { + // Use responsive column widths based on window size + const { columnWidth } = useResponsiveKanban(COLUMNS.length); + return (
-
+
{COLUMNS.map((column) => { const columnFeatures = getColumnFeatures(column.id); return ( @@ -109,6 +113,7 @@ export function KanbanBoard({ title={column.title} colorClass={column.colorClass} count={columnFeatures.length} + width={columnWidth} opacity={backgroundSettings.columnOpacity} showBorder={backgroundSettings.columnBorderEnabled} hideScrollbar={backgroundSettings.hideScrollbar} @@ -225,7 +230,10 @@ export function KanbanBoard({ }} > {activeFeature && ( - + {activeFeature.description} diff --git a/apps/ui/src/hooks/use-responsive-kanban.ts b/apps/ui/src/hooks/use-responsive-kanban.ts new file mode 100644 index 00000000..8f08e908 --- /dev/null +++ b/apps/ui/src/hooks/use-responsive-kanban.ts @@ -0,0 +1,132 @@ +import { useState, useEffect, useCallback } from "react"; +import { useAppStore } from "@/store/app-store"; + +export interface ResponsiveKanbanConfig { + columnWidth: number; + columnMinWidth: number; + columnMaxWidth: number; + gap: number; + padding: number; +} + +// Sidebar dimensions (must match sidebar.tsx values) +const SIDEBAR_COLLAPSED_WIDTH = 64; // w-16 = 64px +const SIDEBAR_EXPANDED_WIDTH = 288; // w-72 = 288px (lg breakpoint) + +/** + * Default configuration for responsive Kanban columns + */ +const DEFAULT_CONFIG: ResponsiveKanbanConfig = { + columnWidth: 288, // 18rem = 288px (w-72) + columnMinWidth: 280, // Minimum column width - increased to ensure usability + columnMaxWidth: 400, // Maximum column width - increased for better scaling + gap: 20, // gap-5 = 20px + padding: 32, // px-4 on both sides = 32px +}; + +export interface UseResponsiveKanbanResult { + columnWidth: number; + containerStyle: React.CSSProperties; + isCompact: boolean; +} + +/** + * Hook to calculate responsive Kanban column widths based on window size. + * Ensures columns scale intelligently to fill available space without + * dead space on the right or content being cut off. + * + * @param columnCount - Number of columns in the Kanban board + * @param config - Optional configuration for column sizing + * @returns Object with calculated column width and container styles + */ +export function useResponsiveKanban( + columnCount: number = 4, + config: Partial = {} +): UseResponsiveKanbanResult { + const { columnMinWidth, columnMaxWidth, gap, padding } = { + ...DEFAULT_CONFIG, + ...config, + }; + + // Get sidebar state from the store to account for its width + const sidebarOpen = useAppStore((state) => state.sidebarOpen); + + const calculateColumnWidth = useCallback(() => { + if (typeof window === "undefined") { + return DEFAULT_CONFIG.columnWidth; + } + + // Determine sidebar width based on viewport and sidebar state + // On screens < 1024px (lg breakpoint), sidebar is always collapsed width visually + const isLargeScreen = window.innerWidth >= 1024; + const sidebarWidth = isLargeScreen && sidebarOpen + ? SIDEBAR_EXPANDED_WIDTH + : SIDEBAR_COLLAPSED_WIDTH; + + // Get the available width (window width minus sidebar and padding) + const availableWidth = window.innerWidth - sidebarWidth - padding; + + // Calculate total gap space needed + const totalGapWidth = gap * (columnCount - 1); + + // Calculate width available for all columns + const widthForColumns = availableWidth - totalGapWidth; + + // Calculate ideal column width + let idealWidth = Math.floor(widthForColumns / columnCount); + + // Clamp to min/max bounds + idealWidth = Math.max(columnMinWidth, Math.min(columnMaxWidth, idealWidth)); + + return idealWidth; + }, [columnCount, columnMinWidth, columnMaxWidth, gap, padding, sidebarOpen]); + + const [columnWidth, setColumnWidth] = useState(() => + calculateColumnWidth() + ); + + useEffect(() => { + if (typeof window === "undefined") return; + + const handleResize = () => { + const newWidth = calculateColumnWidth(); + setColumnWidth(newWidth); + }; + + // Set initial width + handleResize(); + + // Use ResizeObserver for more precise updates if available + if (typeof ResizeObserver !== "undefined") { + const observer = new ResizeObserver(handleResize); + observer.observe(document.body); + + return () => { + observer.disconnect(); + }; + } + + // Fallback to window resize event + window.addEventListener("resize", handleResize); + return () => { + window.removeEventListener("resize", handleResize); + }; + }, [calculateColumnWidth]); + + // Determine if we're in compact mode (columns at minimum width) + const isCompact = columnWidth <= columnMinWidth + 10; + + // Container style to center content and prevent overflow + const containerStyle: React.CSSProperties = { + display: "flex", + gap: `${gap}px`, + height: "100%", + justifyContent: "center", + }; + + return { + columnWidth, + containerStyle, + isCompact, + }; +} diff --git a/apps/ui/src/main.ts b/apps/ui/src/main.ts index e22edc0d..4d84ffb7 100644 --- a/apps/ui/src/main.ts +++ b/apps/ui/src/main.ts @@ -259,10 +259,10 @@ async function waitForServer(maxAttempts = 30): Promise { function createWindow(): void { const iconPath = getIconPath(); const windowOptions: Electron.BrowserWindowConstructorOptions = { - width: 1400, - height: 900, - minWidth: 1024, - minHeight: 700, + width: 1600, + height: 950, + minWidth: 1280, + minHeight: 768, webPreferences: { preload: path.join(__dirname, "preload.js"), contextIsolation: true, diff --git a/apps/ui/tests/kanban-responsive-scaling.spec.ts b/apps/ui/tests/kanban-responsive-scaling.spec.ts new file mode 100644 index 00000000..7cb8370f --- /dev/null +++ b/apps/ui/tests/kanban-responsive-scaling.spec.ts @@ -0,0 +1,186 @@ +/** + * Kanban Board Responsive Scaling Tests + * + * Tests that the Kanban board columns scale intelligently to fill + * the available window space without dead space or content being cut off. + */ + +import { test, expect } from "@playwright/test"; +import * as fs from "fs"; + +import { + waitForNetworkIdle, + createTestGitRepo, + cleanupTempDir, + createTempDirPath, + setupProjectWithPathNoWorktrees, + waitForBoardView, +} from "./utils"; + +// Create unique temp dir for this test run +const TEST_TEMP_DIR = createTempDirPath("kanban-responsive-tests"); + +interface TestRepo { + path: string; + cleanup: () => Promise; +} + +test.describe("Kanban Responsive Scaling Tests", () => { + let testRepo: TestRepo; + + test.beforeAll(async () => { + // Create test temp directory + if (!fs.existsSync(TEST_TEMP_DIR)) { + fs.mkdirSync(TEST_TEMP_DIR, { recursive: true }); + } + }); + + test.beforeEach(async () => { + // Create a fresh test repo for each test + testRepo = await createTestGitRepo(TEST_TEMP_DIR); + }); + + test.afterEach(async () => { + // Cleanup test repo after each test + if (testRepo) { + await testRepo.cleanup(); + } + }); + + test.afterAll(async () => { + // Cleanup temp directory + cleanupTempDir(TEST_TEMP_DIR); + }); + + test("kanban columns should scale to fill available width at different viewport sizes", async ({ + page, + }) => { + // Setup project and navigate to board view + await setupProjectWithPathNoWorktrees(page, testRepo.path); + await page.goto("/"); + await waitForNetworkIdle(page); + await waitForBoardView(page); + + // Wait for the board to fully render + await page.waitForTimeout(500); + + // Get all four kanban columns + const backlogColumn = page.locator('[data-testid="kanban-column-backlog"]'); + const inProgressColumn = page.locator('[data-testid="kanban-column-in_progress"]'); + const waitingApprovalColumn = page.locator('[data-testid="kanban-column-waiting_approval"]'); + const verifiedColumn = page.locator('[data-testid="kanban-column-verified"]'); + + // Verify all columns are visible + await expect(backlogColumn).toBeVisible(); + await expect(inProgressColumn).toBeVisible(); + await expect(waitingApprovalColumn).toBeVisible(); + await expect(verifiedColumn).toBeVisible(); + + // Test at different viewport widths + const viewportWidths = [1024, 1280, 1440, 1920]; + + for (const width of viewportWidths) { + // Set viewport size + await page.setViewportSize({ width, height: 900 }); + await page.waitForTimeout(300); // Wait for resize to take effect + + // Get column widths + const backlogBox = await backlogColumn.boundingBox(); + const inProgressBox = await inProgressColumn.boundingBox(); + const waitingApprovalBox = await waitingApprovalColumn.boundingBox(); + const verifiedBox = await verifiedColumn.boundingBox(); + + expect(backlogBox).not.toBeNull(); + expect(inProgressBox).not.toBeNull(); + expect(waitingApprovalBox).not.toBeNull(); + expect(verifiedBox).not.toBeNull(); + + if (backlogBox && inProgressBox && waitingApprovalBox && verifiedBox) { + // All columns should have the same width + const columnWidths = [ + backlogBox.width, + inProgressBox.width, + waitingApprovalBox.width, + verifiedBox.width, + ]; + + // All columns should be equal width (within 2px tolerance for rounding) + const baseWidth = columnWidths[0]; + for (const columnWidth of columnWidths) { + expect(Math.abs(columnWidth - baseWidth)).toBeLessThan(2); + } + + // Column width should be within expected bounds (240px min, 360px max) + expect(baseWidth).toBeGreaterThanOrEqual(240); + expect(baseWidth).toBeLessThanOrEqual(360); + + // Columns should not overlap (check x positions) + expect(inProgressBox.x).toBeGreaterThan(backlogBox.x + backlogBox.width - 5); + expect(waitingApprovalBox.x).toBeGreaterThan(inProgressBox.x + inProgressBox.width - 5); + expect(verifiedBox.x).toBeGreaterThan(waitingApprovalBox.x + waitingApprovalBox.width - 5); + } + } + }); + + test("kanban columns should be centered in the viewport", async ({ + page, + }) => { + // Setup project and navigate to board view + await setupProjectWithPathNoWorktrees(page, testRepo.path); + await page.goto("/"); + await waitForNetworkIdle(page); + await waitForBoardView(page); + + // Wait for the board to fully render + await page.waitForTimeout(500); + + // Set a specific viewport size + await page.setViewportSize({ width: 1600, height: 900 }); + await page.waitForTimeout(300); + + // Get the first and last columns + const backlogColumn = page.locator('[data-testid="kanban-column-backlog"]'); + const verifiedColumn = page.locator('[data-testid="kanban-column-verified"]'); + + const backlogBox = await backlogColumn.boundingBox(); + const verifiedBox = await verifiedColumn.boundingBox(); + + expect(backlogBox).not.toBeNull(); + expect(verifiedBox).not.toBeNull(); + + if (backlogBox && verifiedBox) { + // Calculate the left and right margins + const leftMargin = backlogBox.x; + const rightMargin = 1600 - (verifiedBox.x + verifiedBox.width); + + // The margins should be roughly equal (columns are centered) + // Allow for some tolerance due to padding and gaps + const marginDifference = Math.abs(leftMargin - rightMargin); + expect(marginDifference).toBeLessThan(50); // Should be reasonably centered + } + }); + + test("kanban columns should have no horizontal scrollbar at standard viewport width", async ({ + page, + }) => { + // Setup project and navigate to board view + await setupProjectWithPathNoWorktrees(page, testRepo.path); + await page.goto("/"); + await waitForNetworkIdle(page); + await waitForBoardView(page); + + // Set a standard viewport size (1400px which is the default window width) + await page.setViewportSize({ width: 1400, height: 900 }); + await page.waitForTimeout(300); + + // Check if horizontal scrollbar is present by comparing scrollWidth and clientWidth + const hasHorizontalScroll = await page.evaluate(() => { + const boardContainer = document.querySelector('[data-testid="board-view"]'); + if (!boardContainer) return false; + return boardContainer.scrollWidth > boardContainer.clientWidth; + }); + + // There should be no horizontal scroll at standard width since columns scale down + expect(hasHorizontalScroll).toBe(false); + }); +});