mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-02-03 21:03:08 +00:00
Rebuild of the kanban scaling logic, and adding constraints to window scaling logic for electron and web
This commit is contained in:
1282
apps/app/server-bundle/package-lock.json
generated
Normal file
1282
apps/app/server-bundle/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
15
apps/app/server-bundle/package.json
Normal file
15
apps/app/server-bundle/package.json
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
{
|
||||||
|
"name": "@automaker/server-bundle",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"type": "module",
|
||||||
|
"main": "dist/index.js",
|
||||||
|
"dependencies": {
|
||||||
|
"@anthropic-ai/claude-agent-sdk": "^0.1.61",
|
||||||
|
"cors": "^2.8.5",
|
||||||
|
"dotenv": "^17.2.3",
|
||||||
|
"express": "^5.1.0",
|
||||||
|
"morgan": "^1.10.1",
|
||||||
|
"node-pty": "1.1.0-beta41",
|
||||||
|
"ws": "^8.18.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -375,6 +375,19 @@ export function Sidebar() {
|
|||||||
return () => mediaQuery.removeEventListener('change', handleResize);
|
return () => mediaQuery.removeEventListener('change', handleResize);
|
||||||
}, [sidebarOpen, toggleSidebar]);
|
}, [sidebarOpen, toggleSidebar]);
|
||||||
|
|
||||||
|
// Update Electron window minWidth when sidebar state changes
|
||||||
|
// This ensures the window can't be resized smaller than what the kanban board needs
|
||||||
|
useEffect(() => {
|
||||||
|
const electronAPI = (
|
||||||
|
window as unknown as {
|
||||||
|
electronAPI?: { updateMinWidth?: (expanded: boolean) => Promise<void> };
|
||||||
|
}
|
||||||
|
).electronAPI;
|
||||||
|
if (electronAPI?.updateMinWidth) {
|
||||||
|
electronAPI.updateMinWidth(sidebarOpen);
|
||||||
|
}
|
||||||
|
}, [sidebarOpen]);
|
||||||
|
|
||||||
// Filtered projects based on search query
|
// Filtered projects based on search query
|
||||||
const filteredProjects = useMemo(() => {
|
const filteredProjects = useMemo(() => {
|
||||||
if (!projectSearchQuery.trim()) {
|
if (!projectSearchQuery.trim()) {
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
|
import { memo } from 'react';
|
||||||
import { memo } from "react";
|
import { useDroppable } from '@dnd-kit/core';
|
||||||
import { useDroppable } from "@dnd-kit/core";
|
import { cn } from '@/lib/utils';
|
||||||
import { cn } from "@/lib/utils";
|
import type { ReactNode } from 'react';
|
||||||
import type { ReactNode } from "react";
|
|
||||||
|
|
||||||
interface KanbanColumnProps {
|
interface KanbanColumnProps {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -39,10 +38,12 @@ export const KanbanColumn = memo(function KanbanColumn({
|
|||||||
<div
|
<div
|
||||||
ref={setNodeRef}
|
ref={setNodeRef}
|
||||||
className={cn(
|
className={cn(
|
||||||
"relative flex flex-col h-full rounded-xl transition-all duration-200",
|
'relative flex flex-col h-full rounded-xl',
|
||||||
!width && "w-72", // Only apply w-72 if no custom width
|
// Only transition ring/shadow for drag-over effect, not width
|
||||||
showBorder && "border border-border/60",
|
'transition-[box-shadow,ring] duration-200',
|
||||||
isOver && "ring-2 ring-primary/30 ring-offset-1 ring-offset-background"
|
!width && 'w-72', // Only apply w-72 if no custom width
|
||||||
|
showBorder && 'border border-border/60',
|
||||||
|
isOver && 'ring-2 ring-primary/30 ring-offset-1 ring-offset-background'
|
||||||
)}
|
)}
|
||||||
style={widthStyle}
|
style={widthStyle}
|
||||||
data-testid={`kanban-column-${id}`}
|
data-testid={`kanban-column-${id}`}
|
||||||
@@ -50,8 +51,8 @@ export const KanbanColumn = memo(function KanbanColumn({
|
|||||||
{/* Background layer with opacity */}
|
{/* Background layer with opacity */}
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"absolute inset-0 rounded-xl backdrop-blur-sm transition-colors duration-200",
|
'absolute inset-0 rounded-xl backdrop-blur-sm transition-colors duration-200',
|
||||||
isOver ? "bg-accent/80" : "bg-card/80"
|
isOver ? 'bg-accent/80' : 'bg-card/80'
|
||||||
)}
|
)}
|
||||||
style={{ opacity: opacity / 100 }}
|
style={{ opacity: opacity / 100 }}
|
||||||
/>
|
/>
|
||||||
@@ -59,11 +60,11 @@ export const KanbanColumn = memo(function KanbanColumn({
|
|||||||
{/* Column Header */}
|
{/* Column Header */}
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"relative z-10 flex items-center gap-3 px-3 py-2.5",
|
'relative z-10 flex items-center gap-3 px-3 py-2.5',
|
||||||
showBorder && "border-b border-border/40"
|
showBorder && 'border-b border-border/40'
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<div className={cn("w-2.5 h-2.5 rounded-full shrink-0", colorClass)} />
|
<div className={cn('w-2.5 h-2.5 rounded-full shrink-0', colorClass)} />
|
||||||
<h3 className="font-semibold text-sm text-foreground/90 flex-1 tracking-tight">{title}</h3>
|
<h3 className="font-semibold text-sm text-foreground/90 flex-1 tracking-tight">{title}</h3>
|
||||||
{headerAction}
|
{headerAction}
|
||||||
<span className="text-xs font-medium text-muted-foreground/80 bg-muted/50 px-2 py-0.5 rounded-md tabular-nums">
|
<span className="text-xs font-medium text-muted-foreground/80 bg-muted/50 px-2 py-0.5 rounded-md tabular-nums">
|
||||||
@@ -74,11 +75,11 @@ export const KanbanColumn = memo(function KanbanColumn({
|
|||||||
{/* Column Content */}
|
{/* Column Content */}
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"relative z-10 flex-1 overflow-y-auto p-2 space-y-2.5",
|
'relative z-10 flex-1 overflow-y-auto p-2 space-y-2.5',
|
||||||
hideScrollbar &&
|
hideScrollbar &&
|
||||||
"[&::-webkit-scrollbar]:hidden [-ms-overflow-style:none] [scrollbar-width:none]",
|
'[&::-webkit-scrollbar]:hidden [-ms-overflow-style:none] [scrollbar-width:none]',
|
||||||
// Smooth scrolling
|
// Smooth scrolling
|
||||||
"scroll-smooth"
|
'scroll-smooth'
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
|
|||||||
@@ -1,22 +1,15 @@
|
|||||||
|
import { DndContext, DragOverlay } from '@dnd-kit/core';
|
||||||
import {
|
import { SortableContext, verticalListSortingStrategy } from '@dnd-kit/sortable';
|
||||||
DndContext,
|
import { Card, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
|
||||||
DragOverlay,
|
import { Button } from '@/components/ui/button';
|
||||||
} from "@dnd-kit/core";
|
import { HotkeyButton } from '@/components/ui/hotkey-button';
|
||||||
import {
|
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
|
||||||
SortableContext,
|
import { KanbanColumn, KanbanCard } from './components';
|
||||||
verticalListSortingStrategy,
|
import { Feature } from '@/store/app-store';
|
||||||
} from "@dnd-kit/sortable";
|
import { FastForward, Lightbulb, Archive } from 'lucide-react';
|
||||||
import { Card, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
|
import { useKeyboardShortcutsConfig } from '@/hooks/use-keyboard-shortcuts';
|
||||||
import { Button } from "@/components/ui/button";
|
import { useResponsiveKanban } from '@/hooks/use-responsive-kanban';
|
||||||
import { HotkeyButton } from "@/components/ui/hotkey-button";
|
import { COLUMNS, ColumnId } from './constants';
|
||||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
|
|
||||||
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 {
|
interface KanbanBoardProps {
|
||||||
sensors: any;
|
sensors: any;
|
||||||
@@ -90,20 +83,18 @@ export function KanbanBoard({
|
|||||||
onArchiveAllVerified,
|
onArchiveAllVerified,
|
||||||
}: KanbanBoardProps) {
|
}: KanbanBoardProps) {
|
||||||
// Use responsive column widths based on window size
|
// Use responsive column widths based on window size
|
||||||
const { columnWidth } = useResponsiveKanban(COLUMNS.length);
|
// containerStyle handles centering and ensures columns fit without horizontal scroll in Electron
|
||||||
|
const { columnWidth, containerStyle } = useResponsiveKanban(COLUMNS.length);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div className="flex-1 overflow-x-hidden px-5 pb-4 relative" style={backgroundImageStyle}>
|
||||||
className="flex-1 overflow-x-auto px-4 pb-4 relative"
|
|
||||||
style={backgroundImageStyle}
|
|
||||||
>
|
|
||||||
<DndContext
|
<DndContext
|
||||||
sensors={sensors}
|
sensors={sensors}
|
||||||
collisionDetection={collisionDetectionStrategy}
|
collisionDetection={collisionDetectionStrategy}
|
||||||
onDragStart={onDragStart}
|
onDragStart={onDragStart}
|
||||||
onDragEnd={onDragEnd}
|
onDragEnd={onDragEnd}
|
||||||
>
|
>
|
||||||
<div className="flex gap-5 h-full py-1 justify-center">
|
<div className="h-full py-1" style={containerStyle}>
|
||||||
{COLUMNS.map((column) => {
|
{COLUMNS.map((column) => {
|
||||||
const columnFeatures = getColumnFeatures(column.id);
|
const columnFeatures = getColumnFeatures(column.id);
|
||||||
return (
|
return (
|
||||||
@@ -118,8 +109,7 @@ export function KanbanBoard({
|
|||||||
showBorder={backgroundSettings.columnBorderEnabled}
|
showBorder={backgroundSettings.columnBorderEnabled}
|
||||||
hideScrollbar={backgroundSettings.hideScrollbar}
|
hideScrollbar={backgroundSettings.hideScrollbar}
|
||||||
headerAction={
|
headerAction={
|
||||||
column.id === "verified" &&
|
column.id === 'verified' && columnFeatures.length > 0 ? (
|
||||||
columnFeatures.length > 0 ? (
|
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
@@ -130,7 +120,7 @@ export function KanbanBoard({
|
|||||||
<Archive className="w-3 h-3 mr-1" />
|
<Archive className="w-3 h-3 mr-1" />
|
||||||
Archive All
|
Archive All
|
||||||
</Button>
|
</Button>
|
||||||
) : column.id === "backlog" ? (
|
) : column.id === 'backlog' ? (
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
@@ -175,9 +165,8 @@ export function KanbanBoard({
|
|||||||
{columnFeatures.map((feature, index) => {
|
{columnFeatures.map((feature, index) => {
|
||||||
// Calculate shortcut key for in-progress cards (first 10 get 1-9, 0)
|
// Calculate shortcut key for in-progress cards (first 10 get 1-9, 0)
|
||||||
let shortcutKey: string | undefined;
|
let shortcutKey: string | undefined;
|
||||||
if (column.id === "in_progress" && index < 10) {
|
if (column.id === 'in_progress' && index < 10) {
|
||||||
shortcutKey =
|
shortcutKey = index === 9 ? '0' : String(index + 1);
|
||||||
index === 9 ? "0" : String(index + 1);
|
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<KanbanCard
|
<KanbanCard
|
||||||
@@ -190,29 +179,19 @@ export function KanbanBoard({
|
|||||||
onResume={() => onResume(feature)}
|
onResume={() => onResume(feature)}
|
||||||
onForceStop={() => onForceStop(feature)}
|
onForceStop={() => onForceStop(feature)}
|
||||||
onManualVerify={() => onManualVerify(feature)}
|
onManualVerify={() => onManualVerify(feature)}
|
||||||
onMoveBackToInProgress={() =>
|
onMoveBackToInProgress={() => onMoveBackToInProgress(feature)}
|
||||||
onMoveBackToInProgress(feature)
|
|
||||||
}
|
|
||||||
onFollowUp={() => onFollowUp(feature)}
|
onFollowUp={() => onFollowUp(feature)}
|
||||||
onComplete={() => onComplete(feature)}
|
onComplete={() => onComplete(feature)}
|
||||||
onImplement={() => onImplement(feature)}
|
onImplement={() => onImplement(feature)}
|
||||||
onViewPlan={() => onViewPlan(feature)}
|
onViewPlan={() => onViewPlan(feature)}
|
||||||
onApprovePlan={() => onApprovePlan(feature)}
|
onApprovePlan={() => onApprovePlan(feature)}
|
||||||
hasContext={featuresWithContext.has(feature.id)}
|
hasContext={featuresWithContext.has(feature.id)}
|
||||||
isCurrentAutoTask={runningAutoTasks.includes(
|
isCurrentAutoTask={runningAutoTasks.includes(feature.id)}
|
||||||
feature.id
|
|
||||||
)}
|
|
||||||
shortcutKey={shortcutKey}
|
shortcutKey={shortcutKey}
|
||||||
opacity={backgroundSettings.cardOpacity}
|
opacity={backgroundSettings.cardOpacity}
|
||||||
glassmorphism={
|
glassmorphism={backgroundSettings.cardGlassmorphism}
|
||||||
backgroundSettings.cardGlassmorphism
|
cardBorderEnabled={backgroundSettings.cardBorderEnabled}
|
||||||
}
|
cardBorderOpacity={backgroundSettings.cardBorderOpacity}
|
||||||
cardBorderEnabled={
|
|
||||||
backgroundSettings.cardBorderEnabled
|
|
||||||
}
|
|
||||||
cardBorderOpacity={
|
|
||||||
backgroundSettings.cardBorderOpacity
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
@@ -225,7 +204,7 @@ export function KanbanBoard({
|
|||||||
<DragOverlay
|
<DragOverlay
|
||||||
dropAnimation={{
|
dropAnimation={{
|
||||||
duration: 200,
|
duration: 200,
|
||||||
easing: "cubic-bezier(0.18, 0.67, 0.6, 1.22)",
|
easing: 'cubic-bezier(0.18, 0.67, 0.6, 1.22)',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{activeFeature && (
|
{activeFeature && (
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useState, useEffect, useCallback } from "react";
|
import { useState, useEffect, useLayoutEffect, useCallback, useRef } from 'react';
|
||||||
|
import { useAppStore } from '@/store/app-store';
|
||||||
|
|
||||||
export interface ResponsiveKanbanConfig {
|
export interface ResponsiveKanbanConfig {
|
||||||
columnWidth: number;
|
columnWidth: number;
|
||||||
@@ -16,13 +17,18 @@ const DEFAULT_CONFIG: ResponsiveKanbanConfig = {
|
|||||||
columnMinWidth: 280, // Minimum column width - increased to ensure usability
|
columnMinWidth: 280, // Minimum column width - increased to ensure usability
|
||||||
columnMaxWidth: 360, // Maximum column width to ensure responsive scaling
|
columnMaxWidth: 360, // Maximum column width to ensure responsive scaling
|
||||||
gap: 20, // gap-5 = 20px
|
gap: 20, // gap-5 = 20px
|
||||||
padding: 32, // px-4 on both sides = 32px
|
padding: 40, // px-5 on both sides = 40px (matches gap between columns)
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Sidebar transition duration (matches sidebar.tsx)
|
||||||
|
const SIDEBAR_TRANSITION_MS = 300;
|
||||||
|
|
||||||
export interface UseResponsiveKanbanResult {
|
export interface UseResponsiveKanbanResult {
|
||||||
columnWidth: number;
|
columnWidth: number;
|
||||||
containerStyle: React.CSSProperties;
|
containerStyle: React.CSSProperties;
|
||||||
isCompact: boolean;
|
isCompact: boolean;
|
||||||
|
totalBoardWidth: number;
|
||||||
|
isInitialized: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -30,9 +36,14 @@ export interface UseResponsiveKanbanResult {
|
|||||||
* Ensures columns scale intelligently to fill available space without
|
* Ensures columns scale intelligently to fill available space without
|
||||||
* dead space on the right or content being cut off.
|
* dead space on the right or content being cut off.
|
||||||
*
|
*
|
||||||
|
* Features:
|
||||||
|
* - Uses useLayoutEffect to calculate width before paint (prevents bounce)
|
||||||
|
* - Observes actual board container for accurate sizing
|
||||||
|
* - Recalculates after sidebar transitions
|
||||||
|
*
|
||||||
* @param columnCount - Number of columns in the Kanban board
|
* @param columnCount - Number of columns in the Kanban board
|
||||||
* @param config - Optional configuration for column sizing
|
* @param config - Optional configuration for column sizing
|
||||||
* @returns Object with calculated column width and container styles
|
* @returns Object with calculated column width, container styles, and metrics
|
||||||
*/
|
*/
|
||||||
export function useResponsiveKanban(
|
export function useResponsiveKanban(
|
||||||
columnCount: number = 4,
|
columnCount: number = 4,
|
||||||
@@ -43,82 +54,141 @@ export function useResponsiveKanban(
|
|||||||
...config,
|
...config,
|
||||||
};
|
};
|
||||||
|
|
||||||
const calculateColumnWidth = useCallback(() => {
|
const sidebarOpen = useAppStore((state) => state.sidebarOpen);
|
||||||
if (typeof window === "undefined") {
|
const resizeTimeoutRef = useRef<ReturnType<typeof setTimeout>>();
|
||||||
return DEFAULT_CONFIG.columnWidth;
|
const [isInitialized, setIsInitialized] = useState(false);
|
||||||
}
|
|
||||||
|
|
||||||
// Get the actual board container width
|
const calculateColumnWidth = useCallback(
|
||||||
// The flex layout already accounts for sidebar width, so we use the container's actual width
|
(containerWidth?: number) => {
|
||||||
const boardContainer = document.querySelector('[data-testid="board-view"]')?.parentElement;
|
if (typeof window === 'undefined') {
|
||||||
const containerWidth = boardContainer
|
return DEFAULT_CONFIG.columnWidth;
|
||||||
? boardContainer.clientWidth
|
}
|
||||||
: window.innerWidth;
|
|
||||||
|
|
||||||
// Get the available width (subtract padding only)
|
// Get the actual board container width
|
||||||
const availableWidth = containerWidth - padding;
|
// The flex layout already accounts for sidebar width, so we use the container's actual width
|
||||||
|
let width = containerWidth;
|
||||||
|
if (width === undefined) {
|
||||||
|
const boardContainer = document.querySelector('[data-testid="board-view"]')?.parentElement;
|
||||||
|
width = boardContainer ? boardContainer.clientWidth : window.innerWidth;
|
||||||
|
}
|
||||||
|
|
||||||
// Calculate total gap space needed
|
// Get the available width (subtract padding only)
|
||||||
const totalGapWidth = gap * (columnCount - 1);
|
const availableWidth = width - padding;
|
||||||
|
|
||||||
// Calculate width available for all columns
|
// Calculate total gap space needed
|
||||||
const widthForColumns = availableWidth - totalGapWidth;
|
const totalGapWidth = gap * (columnCount - 1);
|
||||||
|
|
||||||
// Calculate ideal column width
|
// Calculate width available for all columns
|
||||||
let idealWidth = Math.floor(widthForColumns / columnCount);
|
const widthForColumns = availableWidth - totalGapWidth;
|
||||||
|
|
||||||
// Clamp to min/max bounds
|
// Calculate ideal column width
|
||||||
idealWidth = Math.max(columnMinWidth, Math.min(columnMaxWidth, idealWidth));
|
let idealWidth = Math.floor(widthForColumns / columnCount);
|
||||||
|
|
||||||
return idealWidth;
|
// Clamp to min/max bounds
|
||||||
}, [columnCount, columnMinWidth, columnMaxWidth, gap, padding]);
|
idealWidth = Math.max(columnMinWidth, Math.min(columnMaxWidth, idealWidth));
|
||||||
|
|
||||||
const [columnWidth, setColumnWidth] = useState<number>(() =>
|
return idealWidth;
|
||||||
calculateColumnWidth()
|
},
|
||||||
|
[columnCount, columnMinWidth, columnMaxWidth, gap, padding]
|
||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
const [columnWidth, setColumnWidth] = useState<number>(() => calculateColumnWidth());
|
||||||
if (typeof window === "undefined") return;
|
|
||||||
|
|
||||||
const handleResize = () => {
|
// Use useLayoutEffect to calculate width synchronously before paint
|
||||||
|
// This prevents the "bounce" effect when navigating to the kanban view
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
if (typeof window === 'undefined') return;
|
||||||
|
|
||||||
|
const updateWidth = () => {
|
||||||
|
const newWidth = calculateColumnWidth();
|
||||||
|
setColumnWidth(newWidth);
|
||||||
|
setIsInitialized(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Calculate immediately before paint
|
||||||
|
updateWidth();
|
||||||
|
}, [calculateColumnWidth]);
|
||||||
|
|
||||||
|
// Set up ResizeObserver for ongoing resize handling
|
||||||
|
useEffect(() => {
|
||||||
|
if (typeof window === 'undefined') return;
|
||||||
|
|
||||||
|
const updateWidth = () => {
|
||||||
const newWidth = calculateColumnWidth();
|
const newWidth = calculateColumnWidth();
|
||||||
setColumnWidth(newWidth);
|
setColumnWidth(newWidth);
|
||||||
};
|
};
|
||||||
|
|
||||||
// Set initial width
|
// Debounced update for smooth resize transitions
|
||||||
handleResize();
|
const scheduleUpdate = () => {
|
||||||
|
if (resizeTimeoutRef.current) {
|
||||||
|
clearTimeout(resizeTimeoutRef.current);
|
||||||
|
}
|
||||||
|
resizeTimeoutRef.current = setTimeout(updateWidth, 50);
|
||||||
|
};
|
||||||
|
|
||||||
// Use ResizeObserver for more precise updates if available
|
// Use ResizeObserver on the actual board container for precise updates
|
||||||
if (typeof ResizeObserver !== "undefined") {
|
let resizeObserver: ResizeObserver | null = null;
|
||||||
const observer = new ResizeObserver(handleResize);
|
const boardView = document.querySelector('[data-testid="board-view"]');
|
||||||
observer.observe(document.body);
|
const container = boardView?.parentElement;
|
||||||
|
|
||||||
return () => {
|
if (container && typeof ResizeObserver !== 'undefined') {
|
||||||
observer.disconnect();
|
resizeObserver = new ResizeObserver((entries) => {
|
||||||
};
|
// Use the observed container's width for calculation
|
||||||
|
const entry = entries[0];
|
||||||
|
if (entry) {
|
||||||
|
const containerWidth = entry.contentRect.width;
|
||||||
|
const newWidth = calculateColumnWidth(containerWidth);
|
||||||
|
setColumnWidth(newWidth);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
resizeObserver.observe(container);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fallback to window resize event
|
// Fallback to window resize event
|
||||||
window.addEventListener("resize", handleResize);
|
window.addEventListener('resize', scheduleUpdate);
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
window.removeEventListener("resize", handleResize);
|
if (resizeObserver) {
|
||||||
|
resizeObserver.disconnect();
|
||||||
|
}
|
||||||
|
window.removeEventListener('resize', scheduleUpdate);
|
||||||
|
if (resizeTimeoutRef.current) {
|
||||||
|
clearTimeout(resizeTimeoutRef.current);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
}, [calculateColumnWidth]);
|
}, [calculateColumnWidth]);
|
||||||
|
|
||||||
|
// Re-calculate after sidebar transitions complete
|
||||||
|
useEffect(() => {
|
||||||
|
const timeout = setTimeout(() => {
|
||||||
|
const newWidth = calculateColumnWidth();
|
||||||
|
setColumnWidth(newWidth);
|
||||||
|
}, SIDEBAR_TRANSITION_MS + 50); // Wait for transition to complete
|
||||||
|
|
||||||
|
return () => clearTimeout(timeout);
|
||||||
|
}, [sidebarOpen, calculateColumnWidth]);
|
||||||
|
|
||||||
// Determine if we're in compact mode (columns at minimum width)
|
// Determine if we're in compact mode (columns at minimum width)
|
||||||
const isCompact = columnWidth <= columnMinWidth + 10;
|
const isCompact = columnWidth <= columnMinWidth + 10;
|
||||||
|
|
||||||
// Container style to center content and prevent overflow
|
// Calculate total board width for container sizing
|
||||||
|
const totalBoardWidth = columnWidth * columnCount + gap * (columnCount - 1);
|
||||||
|
|
||||||
|
// Container style to center content
|
||||||
|
// Use flex layout with justify-center to naturally center columns
|
||||||
|
// The parent container has px-4 padding which provides equal left/right margins
|
||||||
const containerStyle: React.CSSProperties = {
|
const containerStyle: React.CSSProperties = {
|
||||||
display: "flex",
|
display: 'flex',
|
||||||
gap: `${gap}px`,
|
gap: `${gap}px`,
|
||||||
height: "100%",
|
height: '100%',
|
||||||
justifyContent: "center",
|
justifyContent: 'center',
|
||||||
};
|
};
|
||||||
|
|
||||||
return {
|
return {
|
||||||
columnWidth,
|
columnWidth,
|
||||||
containerStyle,
|
containerStyle,
|
||||||
isCompact,
|
isCompact,
|
||||||
|
totalBoardWidth,
|
||||||
|
isInitialized,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,11 +5,11 @@
|
|||||||
* Only native features (dialogs, shell) use IPC.
|
* Only native features (dialogs, shell) use IPC.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import path from "path";
|
import path from 'path';
|
||||||
import { spawn, ChildProcess } from "child_process";
|
import { spawn, ChildProcess } from 'child_process';
|
||||||
import fs from "fs";
|
import fs from 'fs';
|
||||||
import http, { Server } from "http";
|
import http, { Server } from 'http';
|
||||||
import { app, BrowserWindow, ipcMain, dialog, shell } from "electron";
|
import { app, BrowserWindow, ipcMain, dialog, shell, screen } from 'electron';
|
||||||
|
|
||||||
// Development environment
|
// Development environment
|
||||||
const isDev = !app.isPackaged;
|
const isDev = !app.isPackaged;
|
||||||
@@ -19,9 +19,9 @@ const VITE_DEV_SERVER_URL = process.env.VITE_DEV_SERVER_URL;
|
|||||||
if (isDev) {
|
if (isDev) {
|
||||||
try {
|
try {
|
||||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||||
require("dotenv").config({ path: path.join(__dirname, "../.env") });
|
require('dotenv').config({ path: path.join(__dirname, '../.env') });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.warn("[Electron] dotenv not available:", (error as Error).message);
|
console.warn('[Electron] dotenv not available:', (error as Error).message);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -31,22 +31,55 @@ let staticServer: Server | null = null;
|
|||||||
const SERVER_PORT = 3008;
|
const SERVER_PORT = 3008;
|
||||||
const STATIC_PORT = 3007;
|
const STATIC_PORT = 3007;
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// Window sizing constants for kanban layout
|
||||||
|
// ============================================
|
||||||
|
// Calculation: 4 columns × 280px + 3 gaps × 20px + 40px padding = 1220px board content
|
||||||
|
// With sidebar expanded (288px): 1220 + 288 = 1508px
|
||||||
|
// With sidebar collapsed (64px): 1220 + 64 = 1284px
|
||||||
|
const COLUMN_MIN_WIDTH = 280;
|
||||||
|
const COLUMN_COUNT = 4;
|
||||||
|
const GAP_SIZE = 20;
|
||||||
|
const BOARD_PADDING = 40; // px-5 on both sides = 40px (matches gap between columns)
|
||||||
|
const SIDEBAR_EXPANDED = 288;
|
||||||
|
const SIDEBAR_COLLAPSED = 64;
|
||||||
|
|
||||||
|
const BOARD_CONTENT_MIN =
|
||||||
|
COLUMN_MIN_WIDTH * COLUMN_COUNT + GAP_SIZE * (COLUMN_COUNT - 1) + BOARD_PADDING;
|
||||||
|
const MIN_WIDTH_EXPANDED = BOARD_CONTENT_MIN + SIDEBAR_EXPANDED; // 1500px
|
||||||
|
const MIN_WIDTH_COLLAPSED = BOARD_CONTENT_MIN + SIDEBAR_COLLAPSED; // 1276px
|
||||||
|
const MIN_HEIGHT = 850; // Ensures sidebar content fits without scrolling
|
||||||
|
const DEFAULT_WIDTH = 1600;
|
||||||
|
const DEFAULT_HEIGHT = 950;
|
||||||
|
|
||||||
|
// Window bounds interface (matches @automaker/types WindowBounds)
|
||||||
|
interface WindowBounds {
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
isMaximized: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Debounce timer for saving window bounds
|
||||||
|
let saveWindowBoundsTimeout: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get icon path - works in both dev and production, cross-platform
|
* Get icon path - works in both dev and production, cross-platform
|
||||||
*/
|
*/
|
||||||
function getIconPath(): string | null {
|
function getIconPath(): string | null {
|
||||||
let iconFile: string;
|
let iconFile: string;
|
||||||
if (process.platform === "win32") {
|
if (process.platform === 'win32') {
|
||||||
iconFile = "icon.ico";
|
iconFile = 'icon.ico';
|
||||||
} else if (process.platform === "darwin") {
|
} else if (process.platform === 'darwin') {
|
||||||
iconFile = "logo_larger.png";
|
iconFile = 'logo_larger.png';
|
||||||
} else {
|
} else {
|
||||||
iconFile = "logo_larger.png";
|
iconFile = 'logo_larger.png';
|
||||||
}
|
}
|
||||||
|
|
||||||
const iconPath = isDev
|
const iconPath = isDev
|
||||||
? path.join(__dirname, "../public", iconFile)
|
? path.join(__dirname, '../public', iconFile)
|
||||||
: path.join(__dirname, "../dist/public", iconFile);
|
: path.join(__dirname, '../dist/public', iconFile);
|
||||||
|
|
||||||
if (!fs.existsSync(iconPath)) {
|
if (!fs.existsSync(iconPath)) {
|
||||||
console.warn(`[Electron] Icon not found at: ${iconPath}`);
|
console.warn(`[Electron] Icon not found at: ${iconPath}`);
|
||||||
@@ -56,22 +89,136 @@ function getIconPath(): string | null {
|
|||||||
return iconPath;
|
return iconPath;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get path to window bounds settings file
|
||||||
|
*/
|
||||||
|
function getWindowBoundsPath(): string {
|
||||||
|
return path.join(app.getPath('userData'), 'window-bounds.json');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load saved window bounds from disk
|
||||||
|
*/
|
||||||
|
function loadWindowBounds(): WindowBounds | null {
|
||||||
|
try {
|
||||||
|
const boundsPath = getWindowBoundsPath();
|
||||||
|
if (fs.existsSync(boundsPath)) {
|
||||||
|
const data = fs.readFileSync(boundsPath, 'utf-8');
|
||||||
|
const bounds = JSON.parse(data) as WindowBounds;
|
||||||
|
// Validate the loaded data has required fields
|
||||||
|
if (
|
||||||
|
typeof bounds.x === 'number' &&
|
||||||
|
typeof bounds.y === 'number' &&
|
||||||
|
typeof bounds.width === 'number' &&
|
||||||
|
typeof bounds.height === 'number'
|
||||||
|
) {
|
||||||
|
return bounds;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('[Electron] Failed to load window bounds:', (error as Error).message);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Save window bounds to disk
|
||||||
|
*/
|
||||||
|
function saveWindowBounds(bounds: WindowBounds): void {
|
||||||
|
try {
|
||||||
|
const boundsPath = getWindowBoundsPath();
|
||||||
|
fs.writeFileSync(boundsPath, JSON.stringify(bounds, null, 2), 'utf-8');
|
||||||
|
console.log('[Electron] Window bounds saved');
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('[Electron] Failed to save window bounds:', (error as Error).message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Schedule a debounced save of window bounds (500ms delay)
|
||||||
|
*/
|
||||||
|
function scheduleSaveWindowBounds(): void {
|
||||||
|
if (!mainWindow || mainWindow.isDestroyed()) return;
|
||||||
|
|
||||||
|
if (saveWindowBoundsTimeout) {
|
||||||
|
clearTimeout(saveWindowBoundsTimeout);
|
||||||
|
}
|
||||||
|
|
||||||
|
saveWindowBoundsTimeout = setTimeout(() => {
|
||||||
|
if (!mainWindow || mainWindow.isDestroyed()) return;
|
||||||
|
|
||||||
|
const isMaximized = mainWindow.isMaximized();
|
||||||
|
// Use getNormalBounds() for maximized windows to save pre-maximized size
|
||||||
|
const bounds = isMaximized ? mainWindow.getNormalBounds() : mainWindow.getBounds();
|
||||||
|
|
||||||
|
saveWindowBounds({
|
||||||
|
x: bounds.x,
|
||||||
|
y: bounds.y,
|
||||||
|
width: bounds.width,
|
||||||
|
height: bounds.height,
|
||||||
|
isMaximized,
|
||||||
|
});
|
||||||
|
}, 500);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate that window bounds are visible on at least one display
|
||||||
|
* Returns adjusted bounds if needed, or null if completely off-screen
|
||||||
|
*/
|
||||||
|
function validateBounds(bounds: WindowBounds): WindowBounds {
|
||||||
|
const displays = screen.getAllDisplays();
|
||||||
|
|
||||||
|
// Check if window center is visible on any display
|
||||||
|
const centerX = bounds.x + bounds.width / 2;
|
||||||
|
const centerY = bounds.y + bounds.height / 2;
|
||||||
|
|
||||||
|
let isVisible = false;
|
||||||
|
for (const display of displays) {
|
||||||
|
const { x, y, width, height } = display.workArea;
|
||||||
|
if (centerX >= x && centerX <= x + width && centerY >= y && centerY <= y + height) {
|
||||||
|
isVisible = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isVisible) {
|
||||||
|
// Window is off-screen, reset to primary display
|
||||||
|
const primaryDisplay = screen.getPrimaryDisplay();
|
||||||
|
const { x, y, width, height } = primaryDisplay.workArea;
|
||||||
|
|
||||||
|
return {
|
||||||
|
x: x + Math.floor((width - bounds.width) / 2),
|
||||||
|
y: y + Math.floor((height - bounds.height) / 2),
|
||||||
|
width: Math.min(bounds.width, width),
|
||||||
|
height: Math.min(bounds.height, height),
|
||||||
|
isMaximized: bounds.isMaximized,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure minimum dimensions
|
||||||
|
return {
|
||||||
|
...bounds,
|
||||||
|
width: Math.max(bounds.width, MIN_WIDTH_EXPANDED),
|
||||||
|
height: Math.max(bounds.height, MIN_HEIGHT),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Start static file server for production builds
|
* Start static file server for production builds
|
||||||
*/
|
*/
|
||||||
async function startStaticServer(): Promise<void> {
|
async function startStaticServer(): Promise<void> {
|
||||||
const staticPath = path.join(__dirname, "../dist");
|
const staticPath = path.join(__dirname, '../dist');
|
||||||
|
|
||||||
staticServer = http.createServer((request, response) => {
|
staticServer = http.createServer((request, response) => {
|
||||||
let filePath = path.join(staticPath, request.url?.split("?")[0] || "/");
|
let filePath = path.join(staticPath, request.url?.split('?')[0] || '/');
|
||||||
|
|
||||||
if (filePath.endsWith("/")) {
|
if (filePath.endsWith('/')) {
|
||||||
filePath = path.join(filePath, "index.html");
|
filePath = path.join(filePath, 'index.html');
|
||||||
} else if (!path.extname(filePath)) {
|
} else if (!path.extname(filePath)) {
|
||||||
// For client-side routing, serve index.html for paths without extensions
|
// For client-side routing, serve index.html for paths without extensions
|
||||||
const possibleFile = filePath + ".html";
|
const possibleFile = filePath + '.html';
|
||||||
if (!fs.existsSync(filePath) && !fs.existsSync(possibleFile)) {
|
if (!fs.existsSync(filePath) && !fs.existsSync(possibleFile)) {
|
||||||
filePath = path.join(staticPath, "index.html");
|
filePath = path.join(staticPath, 'index.html');
|
||||||
} else if (fs.existsSync(possibleFile)) {
|
} else if (fs.existsSync(possibleFile)) {
|
||||||
filePath = possibleFile;
|
filePath = possibleFile;
|
||||||
}
|
}
|
||||||
@@ -79,35 +226,35 @@ async function startStaticServer(): Promise<void> {
|
|||||||
|
|
||||||
fs.stat(filePath, (err, stats) => {
|
fs.stat(filePath, (err, stats) => {
|
||||||
if (err || !stats?.isFile()) {
|
if (err || !stats?.isFile()) {
|
||||||
filePath = path.join(staticPath, "index.html");
|
filePath = path.join(staticPath, 'index.html');
|
||||||
}
|
}
|
||||||
|
|
||||||
fs.readFile(filePath, (error, content) => {
|
fs.readFile(filePath, (error, content) => {
|
||||||
if (error) {
|
if (error) {
|
||||||
response.writeHead(500);
|
response.writeHead(500);
|
||||||
response.end("Server Error");
|
response.end('Server Error');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const ext = path.extname(filePath);
|
const ext = path.extname(filePath);
|
||||||
const contentTypes: Record<string, string> = {
|
const contentTypes: Record<string, string> = {
|
||||||
".html": "text/html",
|
'.html': 'text/html',
|
||||||
".js": "application/javascript",
|
'.js': 'application/javascript',
|
||||||
".css": "text/css",
|
'.css': 'text/css',
|
||||||
".json": "application/json",
|
'.json': 'application/json',
|
||||||
".png": "image/png",
|
'.png': 'image/png',
|
||||||
".jpg": "image/jpeg",
|
'.jpg': 'image/jpeg',
|
||||||
".gif": "image/gif",
|
'.gif': 'image/gif',
|
||||||
".svg": "image/svg+xml",
|
'.svg': 'image/svg+xml',
|
||||||
".ico": "image/x-icon",
|
'.ico': 'image/x-icon',
|
||||||
".woff": "font/woff",
|
'.woff': 'font/woff',
|
||||||
".woff2": "font/woff2",
|
'.woff2': 'font/woff2',
|
||||||
".ttf": "font/ttf",
|
'.ttf': 'font/ttf',
|
||||||
".eot": "application/vnd.ms-fontobject",
|
'.eot': 'application/vnd.ms-fontobject',
|
||||||
};
|
};
|
||||||
|
|
||||||
response.writeHead(200, {
|
response.writeHead(200, {
|
||||||
"Content-Type": contentTypes[ext] || "application/octet-stream",
|
'Content-Type': contentTypes[ext] || 'application/octet-stream',
|
||||||
});
|
});
|
||||||
response.end(content);
|
response.end(content);
|
||||||
});
|
});
|
||||||
@@ -116,12 +263,10 @@ async function startStaticServer(): Promise<void> {
|
|||||||
|
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
staticServer!.listen(STATIC_PORT, () => {
|
staticServer!.listen(STATIC_PORT, () => {
|
||||||
console.log(
|
console.log(`[Electron] Static server running at http://localhost:${STATIC_PORT}`);
|
||||||
`[Electron] Static server running at http://localhost:${STATIC_PORT}`
|
|
||||||
);
|
|
||||||
resolve();
|
resolve();
|
||||||
});
|
});
|
||||||
staticServer!.on("error", reject);
|
staticServer!.on('error', reject);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -134,36 +279,31 @@ async function startServer(): Promise<void> {
|
|||||||
let serverPath: string;
|
let serverPath: string;
|
||||||
|
|
||||||
if (isDev) {
|
if (isDev) {
|
||||||
command = "node";
|
command = 'node';
|
||||||
serverPath = path.join(__dirname, "../../server/src/index.ts");
|
serverPath = path.join(__dirname, '../../server/src/index.ts');
|
||||||
|
|
||||||
const serverNodeModules = path.join(
|
const serverNodeModules = path.join(__dirname, '../../server/node_modules/tsx');
|
||||||
__dirname,
|
const rootNodeModules = path.join(__dirname, '../../../node_modules/tsx');
|
||||||
"../../server/node_modules/tsx"
|
|
||||||
);
|
|
||||||
const rootNodeModules = path.join(__dirname, "../../../node_modules/tsx");
|
|
||||||
|
|
||||||
let tsxCliPath: string;
|
let tsxCliPath: string;
|
||||||
if (fs.existsSync(path.join(serverNodeModules, "dist/cli.mjs"))) {
|
if (fs.existsSync(path.join(serverNodeModules, 'dist/cli.mjs'))) {
|
||||||
tsxCliPath = path.join(serverNodeModules, "dist/cli.mjs");
|
tsxCliPath = path.join(serverNodeModules, 'dist/cli.mjs');
|
||||||
} else if (fs.existsSync(path.join(rootNodeModules, "dist/cli.mjs"))) {
|
} else if (fs.existsSync(path.join(rootNodeModules, 'dist/cli.mjs'))) {
|
||||||
tsxCliPath = path.join(rootNodeModules, "dist/cli.mjs");
|
tsxCliPath = path.join(rootNodeModules, 'dist/cli.mjs');
|
||||||
} else {
|
} else {
|
||||||
try {
|
try {
|
||||||
tsxCliPath = require.resolve("tsx/cli.mjs", {
|
tsxCliPath = require.resolve('tsx/cli.mjs', {
|
||||||
paths: [path.join(__dirname, "../../server")],
|
paths: [path.join(__dirname, '../../server')],
|
||||||
});
|
});
|
||||||
} catch {
|
} catch {
|
||||||
throw new Error(
|
throw new Error("Could not find tsx. Please run 'npm install' in the server directory.");
|
||||||
"Could not find tsx. Please run 'npm install' in the server directory."
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
args = [tsxCliPath, "watch", serverPath];
|
args = [tsxCliPath, 'watch', serverPath];
|
||||||
} else {
|
} else {
|
||||||
command = "node";
|
command = 'node';
|
||||||
serverPath = path.join(process.resourcesPath, "server", "index.js");
|
serverPath = path.join(process.resourcesPath, 'server', 'index.js');
|
||||||
args = [serverPath];
|
args = [serverPath];
|
||||||
|
|
||||||
if (!fs.existsSync(serverPath)) {
|
if (!fs.existsSync(serverPath)) {
|
||||||
@@ -172,13 +312,13 @@ async function startServer(): Promise<void> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const serverNodeModules = app.isPackaged
|
const serverNodeModules = app.isPackaged
|
||||||
? path.join(process.resourcesPath, "server", "node_modules")
|
? path.join(process.resourcesPath, 'server', 'node_modules')
|
||||||
: path.join(__dirname, "../../server/node_modules");
|
: path.join(__dirname, '../../server/node_modules');
|
||||||
|
|
||||||
const env = {
|
const env = {
|
||||||
...process.env,
|
...process.env,
|
||||||
PORT: SERVER_PORT.toString(),
|
PORT: SERVER_PORT.toString(),
|
||||||
DATA_DIR: app.getPath("userData"),
|
DATA_DIR: app.getPath('userData'),
|
||||||
NODE_PATH: serverNodeModules,
|
NODE_PATH: serverNodeModules,
|
||||||
// Only set ALLOWED_ROOT_DIRECTORY if explicitly provided in environment
|
// Only set ALLOWED_ROOT_DIRECTORY if explicitly provided in environment
|
||||||
// If not set, server will allow access to all paths
|
// If not set, server will allow access to all paths
|
||||||
@@ -187,30 +327,30 @@ async function startServer(): Promise<void> {
|
|||||||
}),
|
}),
|
||||||
};
|
};
|
||||||
|
|
||||||
console.log("[Electron] Starting backend server...");
|
console.log('[Electron] Starting backend server...');
|
||||||
console.log("[Electron] Server path:", serverPath);
|
console.log('[Electron] Server path:', serverPath);
|
||||||
console.log("[Electron] NODE_PATH:", serverNodeModules);
|
console.log('[Electron] NODE_PATH:', serverNodeModules);
|
||||||
|
|
||||||
serverProcess = spawn(command, args, {
|
serverProcess = spawn(command, args, {
|
||||||
cwd: path.dirname(serverPath),
|
cwd: path.dirname(serverPath),
|
||||||
env,
|
env,
|
||||||
stdio: ["ignore", "pipe", "pipe"],
|
stdio: ['ignore', 'pipe', 'pipe'],
|
||||||
});
|
});
|
||||||
|
|
||||||
serverProcess.stdout?.on("data", (data) => {
|
serverProcess.stdout?.on('data', (data) => {
|
||||||
console.log(`[Server] ${data.toString().trim()}`);
|
console.log(`[Server] ${data.toString().trim()}`);
|
||||||
});
|
});
|
||||||
|
|
||||||
serverProcess.stderr?.on("data", (data) => {
|
serverProcess.stderr?.on('data', (data) => {
|
||||||
console.error(`[Server Error] ${data.toString().trim()}`);
|
console.error(`[Server Error] ${data.toString().trim()}`);
|
||||||
});
|
});
|
||||||
|
|
||||||
serverProcess.on("close", (code) => {
|
serverProcess.on('close', (code) => {
|
||||||
console.log(`[Server] Process exited with code ${code}`);
|
console.log(`[Server] Process exited with code ${code}`);
|
||||||
serverProcess = null;
|
serverProcess = null;
|
||||||
});
|
});
|
||||||
|
|
||||||
serverProcess.on("error", (err) => {
|
serverProcess.on('error', (err) => {
|
||||||
console.error(`[Server] Failed to start server process:`, err);
|
console.error(`[Server] Failed to start server process:`, err);
|
||||||
serverProcess = null;
|
serverProcess = null;
|
||||||
});
|
});
|
||||||
@@ -225,30 +365,27 @@ async function waitForServer(maxAttempts = 30): Promise<void> {
|
|||||||
for (let i = 0; i < maxAttempts; i++) {
|
for (let i = 0; i < maxAttempts; i++) {
|
||||||
try {
|
try {
|
||||||
await new Promise<void>((resolve, reject) => {
|
await new Promise<void>((resolve, reject) => {
|
||||||
const req = http.get(
|
const req = http.get(`http://localhost:${SERVER_PORT}/api/health`, (res) => {
|
||||||
`http://localhost:${SERVER_PORT}/api/health`,
|
if (res.statusCode === 200) {
|
||||||
(res) => {
|
resolve();
|
||||||
if (res.statusCode === 200) {
|
} else {
|
||||||
resolve();
|
reject(new Error(`Status: ${res.statusCode}`));
|
||||||
} else {
|
|
||||||
reject(new Error(`Status: ${res.statusCode}`));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
);
|
});
|
||||||
req.on("error", reject);
|
req.on('error', reject);
|
||||||
req.setTimeout(1000, () => {
|
req.setTimeout(1000, () => {
|
||||||
req.destroy();
|
req.destroy();
|
||||||
reject(new Error("Timeout"));
|
reject(new Error('Timeout'));
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
console.log("[Electron] Server is ready");
|
console.log('[Electron] Server is ready');
|
||||||
return;
|
return;
|
||||||
} catch {
|
} catch {
|
||||||
await new Promise((r) => setTimeout(r, 500));
|
await new Promise((r) => setTimeout(r, 500));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
throw new Error("Server failed to start");
|
throw new Error('Server failed to start');
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -256,18 +393,25 @@ async function waitForServer(maxAttempts = 30): Promise<void> {
|
|||||||
*/
|
*/
|
||||||
function createWindow(): void {
|
function createWindow(): void {
|
||||||
const iconPath = getIconPath();
|
const iconPath = getIconPath();
|
||||||
|
|
||||||
|
// Load and validate saved window bounds
|
||||||
|
const savedBounds = loadWindowBounds();
|
||||||
|
const validBounds = savedBounds ? validateBounds(savedBounds) : null;
|
||||||
|
|
||||||
const windowOptions: Electron.BrowserWindowConstructorOptions = {
|
const windowOptions: Electron.BrowserWindowConstructorOptions = {
|
||||||
width: 1600,
|
width: validBounds?.width ?? DEFAULT_WIDTH,
|
||||||
height: 950,
|
height: validBounds?.height ?? DEFAULT_HEIGHT,
|
||||||
minWidth: 1280,
|
x: validBounds?.x,
|
||||||
minHeight: 768,
|
y: validBounds?.y,
|
||||||
|
minWidth: MIN_WIDTH_EXPANDED, // 1500px - ensures kanban columns fit with sidebar
|
||||||
|
minHeight: MIN_HEIGHT,
|
||||||
webPreferences: {
|
webPreferences: {
|
||||||
preload: path.join(__dirname, "preload.js"),
|
preload: path.join(__dirname, 'preload.js'),
|
||||||
contextIsolation: true,
|
contextIsolation: true,
|
||||||
nodeIntegration: false,
|
nodeIntegration: false,
|
||||||
},
|
},
|
||||||
titleBarStyle: "hiddenInset",
|
titleBarStyle: 'hiddenInset',
|
||||||
backgroundColor: "#0a0a0a",
|
backgroundColor: '#0a0a0a',
|
||||||
};
|
};
|
||||||
|
|
||||||
if (iconPath) {
|
if (iconPath) {
|
||||||
@@ -276,6 +420,11 @@ function createWindow(): void {
|
|||||||
|
|
||||||
mainWindow = new BrowserWindow(windowOptions);
|
mainWindow = new BrowserWindow(windowOptions);
|
||||||
|
|
||||||
|
// Restore maximized state if previously maximized
|
||||||
|
if (validBounds?.isMaximized) {
|
||||||
|
mainWindow.maximize();
|
||||||
|
}
|
||||||
|
|
||||||
// Load Vite dev server in development or static server in production
|
// Load Vite dev server in development or static server in production
|
||||||
if (VITE_DEV_SERVER_URL) {
|
if (VITE_DEV_SERVER_URL) {
|
||||||
mainWindow.loadURL(VITE_DEV_SERVER_URL);
|
mainWindow.loadURL(VITE_DEV_SERVER_URL);
|
||||||
@@ -286,17 +435,42 @@ function createWindow(): void {
|
|||||||
mainWindow.loadURL(`http://localhost:${STATIC_PORT}`);
|
mainWindow.loadURL(`http://localhost:${STATIC_PORT}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isDev && process.env.OPEN_DEVTOOLS === "true") {
|
if (isDev && process.env.OPEN_DEVTOOLS === 'true') {
|
||||||
mainWindow.webContents.openDevTools();
|
mainWindow.webContents.openDevTools();
|
||||||
}
|
}
|
||||||
|
|
||||||
mainWindow.on("closed", () => {
|
// Save window bounds on close, resize, and move
|
||||||
|
mainWindow.on('close', () => {
|
||||||
|
// Save immediately before closing (not debounced)
|
||||||
|
if (mainWindow && !mainWindow.isDestroyed()) {
|
||||||
|
const isMaximized = mainWindow.isMaximized();
|
||||||
|
const bounds = isMaximized ? mainWindow.getNormalBounds() : mainWindow.getBounds();
|
||||||
|
|
||||||
|
saveWindowBounds({
|
||||||
|
x: bounds.x,
|
||||||
|
y: bounds.y,
|
||||||
|
width: bounds.width,
|
||||||
|
height: bounds.height,
|
||||||
|
isMaximized,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
mainWindow.on('closed', () => {
|
||||||
mainWindow = null;
|
mainWindow = null;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
mainWindow.on('resized', () => {
|
||||||
|
scheduleSaveWindowBounds();
|
||||||
|
});
|
||||||
|
|
||||||
|
mainWindow.on('moved', () => {
|
||||||
|
scheduleSaveWindowBounds();
|
||||||
|
});
|
||||||
|
|
||||||
mainWindow.webContents.setWindowOpenHandler(({ url }) => {
|
mainWindow.webContents.setWindowOpenHandler(({ url }) => {
|
||||||
shell.openExternal(url);
|
shell.openExternal(url);
|
||||||
return { action: "deny" };
|
return { action: 'deny' };
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -304,28 +478,22 @@ function createWindow(): void {
|
|||||||
app.whenReady().then(async () => {
|
app.whenReady().then(async () => {
|
||||||
// Ensure userData path is consistent across dev/prod so files land in Automaker dir
|
// Ensure userData path is consistent across dev/prod so files land in Automaker dir
|
||||||
try {
|
try {
|
||||||
const desiredUserDataPath = path.join(app.getPath("appData"), "Automaker");
|
const desiredUserDataPath = path.join(app.getPath('appData'), 'Automaker');
|
||||||
if (app.getPath("userData") !== desiredUserDataPath) {
|
if (app.getPath('userData') !== desiredUserDataPath) {
|
||||||
app.setPath("userData", desiredUserDataPath);
|
app.setPath('userData', desiredUserDataPath);
|
||||||
console.log("[Electron] userData path set to:", desiredUserDataPath);
|
console.log('[Electron] userData path set to:', desiredUserDataPath);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.warn(
|
console.warn('[Electron] Failed to set userData path:', (error as Error).message);
|
||||||
"[Electron] Failed to set userData path:",
|
|
||||||
(error as Error).message
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (process.platform === "darwin" && app.dock) {
|
if (process.platform === 'darwin' && app.dock) {
|
||||||
const iconPath = getIconPath();
|
const iconPath = getIconPath();
|
||||||
if (iconPath) {
|
if (iconPath) {
|
||||||
try {
|
try {
|
||||||
app.dock.setIcon(iconPath);
|
app.dock.setIcon(iconPath);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.warn(
|
console.warn('[Electron] Failed to set dock icon:', (error as Error).message);
|
||||||
"[Electron] Failed to set dock icon:",
|
|
||||||
(error as Error).message
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -342,32 +510,32 @@ app.whenReady().then(async () => {
|
|||||||
// Create window
|
// Create window
|
||||||
createWindow();
|
createWindow();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("[Electron] Failed to start:", error);
|
console.error('[Electron] Failed to start:', error);
|
||||||
app.quit();
|
app.quit();
|
||||||
}
|
}
|
||||||
|
|
||||||
app.on("activate", () => {
|
app.on('activate', () => {
|
||||||
if (BrowserWindow.getAllWindows().length === 0) {
|
if (BrowserWindow.getAllWindows().length === 0) {
|
||||||
createWindow();
|
createWindow();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
app.on("window-all-closed", () => {
|
app.on('window-all-closed', () => {
|
||||||
if (process.platform !== "darwin") {
|
if (process.platform !== 'darwin') {
|
||||||
app.quit();
|
app.quit();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
app.on("before-quit", () => {
|
app.on('before-quit', () => {
|
||||||
if (serverProcess) {
|
if (serverProcess) {
|
||||||
console.log("[Electron] Stopping server...");
|
console.log('[Electron] Stopping server...');
|
||||||
serverProcess.kill();
|
serverProcess.kill();
|
||||||
serverProcess = null;
|
serverProcess = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (staticServer) {
|
if (staticServer) {
|
||||||
console.log("[Electron] Stopping static server...");
|
console.log('[Electron] Stopping static server...');
|
||||||
staticServer.close();
|
staticServer.close();
|
||||||
staticServer = null;
|
staticServer = null;
|
||||||
}
|
}
|
||||||
@@ -378,28 +546,28 @@ app.on("before-quit", () => {
|
|||||||
// ============================================
|
// ============================================
|
||||||
|
|
||||||
// Native file dialogs
|
// Native file dialogs
|
||||||
ipcMain.handle("dialog:openDirectory", async () => {
|
ipcMain.handle('dialog:openDirectory', async () => {
|
||||||
if (!mainWindow) {
|
if (!mainWindow) {
|
||||||
return { canceled: true, filePaths: [] };
|
return { canceled: true, filePaths: [] };
|
||||||
}
|
}
|
||||||
const result = await dialog.showOpenDialog(mainWindow, {
|
const result = await dialog.showOpenDialog(mainWindow, {
|
||||||
properties: ["openDirectory", "createDirectory"],
|
properties: ['openDirectory', 'createDirectory'],
|
||||||
});
|
});
|
||||||
return result;
|
return result;
|
||||||
});
|
});
|
||||||
|
|
||||||
ipcMain.handle("dialog:openFile", async (_, options = {}) => {
|
ipcMain.handle('dialog:openFile', async (_, options = {}) => {
|
||||||
if (!mainWindow) {
|
if (!mainWindow) {
|
||||||
return { canceled: true, filePaths: [] };
|
return { canceled: true, filePaths: [] };
|
||||||
}
|
}
|
||||||
const result = await dialog.showOpenDialog(mainWindow, {
|
const result = await dialog.showOpenDialog(mainWindow, {
|
||||||
properties: ["openFile"],
|
properties: ['openFile'],
|
||||||
...options,
|
...options,
|
||||||
});
|
});
|
||||||
return result;
|
return result;
|
||||||
});
|
});
|
||||||
|
|
||||||
ipcMain.handle("dialog:saveFile", async (_, options = {}) => {
|
ipcMain.handle('dialog:saveFile', async (_, options = {}) => {
|
||||||
if (!mainWindow) {
|
if (!mainWindow) {
|
||||||
return { canceled: true, filePath: undefined };
|
return { canceled: true, filePath: undefined };
|
||||||
}
|
}
|
||||||
@@ -408,7 +576,7 @@ ipcMain.handle("dialog:saveFile", async (_, options = {}) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Shell operations
|
// Shell operations
|
||||||
ipcMain.handle("shell:openExternal", async (_, url: string) => {
|
ipcMain.handle('shell:openExternal', async (_, url: string) => {
|
||||||
try {
|
try {
|
||||||
await shell.openExternal(url);
|
await shell.openExternal(url);
|
||||||
return { success: true };
|
return { success: true };
|
||||||
@@ -417,7 +585,7 @@ ipcMain.handle("shell:openExternal", async (_, url: string) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
ipcMain.handle("shell:openPath", async (_, filePath: string) => {
|
ipcMain.handle('shell:openPath', async (_, filePath: string) => {
|
||||||
try {
|
try {
|
||||||
await shell.openPath(filePath);
|
await shell.openPath(filePath);
|
||||||
return { success: true };
|
return { success: true };
|
||||||
@@ -427,27 +595,38 @@ ipcMain.handle("shell:openPath", async (_, filePath: string) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// App info
|
// App info
|
||||||
ipcMain.handle(
|
ipcMain.handle('app:getPath', async (_, name: Parameters<typeof app.getPath>[0]) => {
|
||||||
"app:getPath",
|
return app.getPath(name);
|
||||||
async (_, name: Parameters<typeof app.getPath>[0]) => {
|
});
|
||||||
return app.getPath(name);
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
ipcMain.handle("app:getVersion", async () => {
|
ipcMain.handle('app:getVersion', async () => {
|
||||||
return app.getVersion();
|
return app.getVersion();
|
||||||
});
|
});
|
||||||
|
|
||||||
ipcMain.handle("app:isPackaged", async () => {
|
ipcMain.handle('app:isPackaged', async () => {
|
||||||
return app.isPackaged;
|
return app.isPackaged;
|
||||||
});
|
});
|
||||||
|
|
||||||
// Ping - for connection check
|
// Ping - for connection check
|
||||||
ipcMain.handle("ping", async () => {
|
ipcMain.handle('ping', async () => {
|
||||||
return "pong";
|
return 'pong';
|
||||||
});
|
});
|
||||||
|
|
||||||
// Get server URL for HTTP client
|
// Get server URL for HTTP client
|
||||||
ipcMain.handle("server:getUrl", async () => {
|
ipcMain.handle('server:getUrl', async () => {
|
||||||
return `http://localhost:${SERVER_PORT}`;
|
return `http://localhost:${SERVER_PORT}`;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Window management - update minimum width based on sidebar state
|
||||||
|
ipcMain.handle('window:updateMinWidth', (_, sidebarExpanded: boolean) => {
|
||||||
|
if (!mainWindow || mainWindow.isDestroyed()) return;
|
||||||
|
|
||||||
|
const minWidth = sidebarExpanded ? MIN_WIDTH_EXPANDED : MIN_WIDTH_COLLAPSED;
|
||||||
|
mainWindow.setMinimumSize(minWidth, MIN_HEIGHT);
|
||||||
|
|
||||||
|
// If current width is below new minimum, resize window
|
||||||
|
const currentBounds = mainWindow.getBounds();
|
||||||
|
if (currentBounds.width < minWidth) {
|
||||||
|
mainWindow.setSize(minWidth, currentBounds.height);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|||||||
@@ -5,38 +5,42 @@
|
|||||||
* All other operations go through HTTP API.
|
* All other operations go through HTTP API.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { contextBridge, ipcRenderer, OpenDialogOptions, SaveDialogOptions } from "electron";
|
import { contextBridge, ipcRenderer, OpenDialogOptions, SaveDialogOptions } from 'electron';
|
||||||
|
|
||||||
// Expose minimal API for native features
|
// Expose minimal API for native features
|
||||||
contextBridge.exposeInMainWorld("electronAPI", {
|
contextBridge.exposeInMainWorld('electronAPI', {
|
||||||
// Platform info
|
// Platform info
|
||||||
platform: process.platform,
|
platform: process.platform,
|
||||||
isElectron: true,
|
isElectron: true,
|
||||||
|
|
||||||
// Connection check
|
// Connection check
|
||||||
ping: (): Promise<string> => ipcRenderer.invoke("ping"),
|
ping: (): Promise<string> => ipcRenderer.invoke('ping'),
|
||||||
|
|
||||||
// Get server URL for HTTP client
|
// Get server URL for HTTP client
|
||||||
getServerUrl: (): Promise<string> => ipcRenderer.invoke("server:getUrl"),
|
getServerUrl: (): Promise<string> => ipcRenderer.invoke('server:getUrl'),
|
||||||
|
|
||||||
// Native dialogs - better UX than prompt()
|
// Native dialogs - better UX than prompt()
|
||||||
openDirectory: (): Promise<Electron.OpenDialogReturnValue> =>
|
openDirectory: (): Promise<Electron.OpenDialogReturnValue> =>
|
||||||
ipcRenderer.invoke("dialog:openDirectory"),
|
ipcRenderer.invoke('dialog:openDirectory'),
|
||||||
openFile: (options?: OpenDialogOptions): Promise<Electron.OpenDialogReturnValue> =>
|
openFile: (options?: OpenDialogOptions): Promise<Electron.OpenDialogReturnValue> =>
|
||||||
ipcRenderer.invoke("dialog:openFile", options),
|
ipcRenderer.invoke('dialog:openFile', options),
|
||||||
saveFile: (options?: SaveDialogOptions): Promise<Electron.SaveDialogReturnValue> =>
|
saveFile: (options?: SaveDialogOptions): Promise<Electron.SaveDialogReturnValue> =>
|
||||||
ipcRenderer.invoke("dialog:saveFile", options),
|
ipcRenderer.invoke('dialog:saveFile', options),
|
||||||
|
|
||||||
// Shell operations
|
// Shell operations
|
||||||
openExternalLink: (url: string): Promise<{ success: boolean; error?: string }> =>
|
openExternalLink: (url: string): Promise<{ success: boolean; error?: string }> =>
|
||||||
ipcRenderer.invoke("shell:openExternal", url),
|
ipcRenderer.invoke('shell:openExternal', url),
|
||||||
openPath: (filePath: string): Promise<{ success: boolean; error?: string }> =>
|
openPath: (filePath: string): Promise<{ success: boolean; error?: string }> =>
|
||||||
ipcRenderer.invoke("shell:openPath", filePath),
|
ipcRenderer.invoke('shell:openPath', filePath),
|
||||||
|
|
||||||
// App info
|
// App info
|
||||||
getPath: (name: string): Promise<string> => ipcRenderer.invoke("app:getPath", name),
|
getPath: (name: string): Promise<string> => ipcRenderer.invoke('app:getPath', name),
|
||||||
getVersion: (): Promise<string> => ipcRenderer.invoke("app:getVersion"),
|
getVersion: (): Promise<string> => ipcRenderer.invoke('app:getVersion'),
|
||||||
isPackaged: (): Promise<boolean> => ipcRenderer.invoke("app:isPackaged"),
|
isPackaged: (): Promise<boolean> => ipcRenderer.invoke('app:isPackaged'),
|
||||||
|
|
||||||
|
// Window management
|
||||||
|
updateMinWidth: (sidebarExpanded: boolean): Promise<void> =>
|
||||||
|
ipcRenderer.invoke('window:updateMinWidth', sidebarExpanded),
|
||||||
});
|
});
|
||||||
|
|
||||||
console.log("[Preload] Electron API exposed (TypeScript)");
|
console.log('[Preload] Electron API exposed (TypeScript)');
|
||||||
|
|||||||
@@ -5,8 +5,8 @@
|
|||||||
* the available window space without dead space or content being cut off.
|
* the available window space without dead space or content being cut off.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { test, expect } from "@playwright/test";
|
import { test, expect } from '@playwright/test';
|
||||||
import * as fs from "fs";
|
import * as fs from 'fs';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
waitForNetworkIdle,
|
waitForNetworkIdle,
|
||||||
@@ -15,17 +15,17 @@ import {
|
|||||||
createTempDirPath,
|
createTempDirPath,
|
||||||
setupProjectWithPathNoWorktrees,
|
setupProjectWithPathNoWorktrees,
|
||||||
waitForBoardView,
|
waitForBoardView,
|
||||||
} from "./utils";
|
} from './utils';
|
||||||
|
|
||||||
// Create unique temp dir for this test run
|
// Create unique temp dir for this test run
|
||||||
const TEST_TEMP_DIR = createTempDirPath("kanban-responsive-tests");
|
const TEST_TEMP_DIR = createTempDirPath('kanban-responsive-tests');
|
||||||
|
|
||||||
interface TestRepo {
|
interface TestRepo {
|
||||||
path: string;
|
path: string;
|
||||||
cleanup: () => Promise<void>;
|
cleanup: () => Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
test.describe("Kanban Responsive Scaling Tests", () => {
|
test.describe('Kanban Responsive Scaling Tests', () => {
|
||||||
let testRepo: TestRepo;
|
let testRepo: TestRepo;
|
||||||
|
|
||||||
test.beforeAll(async () => {
|
test.beforeAll(async () => {
|
||||||
@@ -52,12 +52,12 @@ test.describe("Kanban Responsive Scaling Tests", () => {
|
|||||||
cleanupTempDir(TEST_TEMP_DIR);
|
cleanupTempDir(TEST_TEMP_DIR);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("kanban columns should scale to fill available width at different viewport sizes", async ({
|
test('kanban columns should scale to fill available width at different viewport sizes', async ({
|
||||||
page,
|
page,
|
||||||
}) => {
|
}) => {
|
||||||
// Setup project and navigate to board view
|
// Setup project and navigate to board view
|
||||||
await setupProjectWithPathNoWorktrees(page, testRepo.path);
|
await setupProjectWithPathNoWorktrees(page, testRepo.path);
|
||||||
await page.goto("/");
|
await page.goto('/');
|
||||||
await waitForNetworkIdle(page);
|
await waitForNetworkIdle(page);
|
||||||
await waitForBoardView(page);
|
await waitForBoardView(page);
|
||||||
|
|
||||||
@@ -122,12 +122,10 @@ test.describe("Kanban Responsive Scaling Tests", () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
test("kanban columns should be centered in the viewport", async ({
|
test('kanban columns should be centered in the viewport', async ({ page }) => {
|
||||||
page,
|
|
||||||
}) => {
|
|
||||||
// Setup project and navigate to board view
|
// Setup project and navigate to board view
|
||||||
await setupProjectWithPathNoWorktrees(page, testRepo.path);
|
await setupProjectWithPathNoWorktrees(page, testRepo.path);
|
||||||
await page.goto("/");
|
await page.goto('/');
|
||||||
await waitForNetworkIdle(page);
|
await waitForNetworkIdle(page);
|
||||||
await waitForBoardView(page);
|
await waitForBoardView(page);
|
||||||
|
|
||||||
@@ -181,12 +179,12 @@ test.describe("Kanban Responsive Scaling Tests", () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
test("kanban columns should have no horizontal scrollbar at standard viewport width", async ({
|
test('kanban columns should have no horizontal scrollbar at standard viewport width', async ({
|
||||||
page,
|
page,
|
||||||
}) => {
|
}) => {
|
||||||
// Setup project and navigate to board view
|
// Setup project and navigate to board view
|
||||||
await setupProjectWithPathNoWorktrees(page, testRepo.path);
|
await setupProjectWithPathNoWorktrees(page, testRepo.path);
|
||||||
await page.goto("/");
|
await page.goto('/');
|
||||||
await waitForNetworkIdle(page);
|
await waitForNetworkIdle(page);
|
||||||
await waitForBoardView(page);
|
await waitForBoardView(page);
|
||||||
|
|
||||||
@@ -204,4 +202,92 @@ test.describe("Kanban Responsive Scaling Tests", () => {
|
|||||||
// There should be no horizontal scroll at standard width since columns scale down
|
// There should be no horizontal scroll at standard width since columns scale down
|
||||||
expect(hasHorizontalScroll).toBe(false);
|
expect(hasHorizontalScroll).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('kanban columns should fit at minimum width (1500px - Electron minimum)', async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
// Setup project and navigate to board view
|
||||||
|
await setupProjectWithPathNoWorktrees(page, testRepo.path);
|
||||||
|
await page.goto('/');
|
||||||
|
await waitForNetworkIdle(page);
|
||||||
|
await waitForBoardView(page);
|
||||||
|
|
||||||
|
// Set viewport to the new Electron minimum width (1500px)
|
||||||
|
await page.setViewportSize({ width: 1500, height: 900 });
|
||||||
|
await page.waitForTimeout(300);
|
||||||
|
|
||||||
|
// Get all four kanban columns
|
||||||
|
const backlogColumn = page.locator('[data-testid="kanban-column-backlog"]');
|
||||||
|
const verifiedColumn = page.locator('[data-testid="kanban-column-verified"]');
|
||||||
|
|
||||||
|
// Verify columns are visible
|
||||||
|
await expect(backlogColumn).toBeVisible();
|
||||||
|
await expect(verifiedColumn).toBeVisible();
|
||||||
|
|
||||||
|
// Check if horizontal scrollbar is present
|
||||||
|
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 minimum width
|
||||||
|
expect(hasHorizontalScroll).toBe(false);
|
||||||
|
|
||||||
|
// Verify columns are at minimum width (280px)
|
||||||
|
const backlogBox = await backlogColumn.boundingBox();
|
||||||
|
expect(backlogBox).not.toBeNull();
|
||||||
|
if (backlogBox) {
|
||||||
|
expect(backlogBox.width).toBeGreaterThanOrEqual(280);
|
||||||
|
expect(backlogBox.width).toBeLessThanOrEqual(360);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('kanban columns should scale correctly when sidebar is collapsed', 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 viewport size
|
||||||
|
await page.setViewportSize({ width: 1600, height: 900 });
|
||||||
|
await page.waitForTimeout(300);
|
||||||
|
|
||||||
|
// Get initial column width
|
||||||
|
const backlogColumn = page.locator('[data-testid="kanban-column-backlog"]');
|
||||||
|
const initialBox = await backlogColumn.boundingBox();
|
||||||
|
expect(initialBox).not.toBeNull();
|
||||||
|
|
||||||
|
// Find and click the sidebar collapse button
|
||||||
|
const collapseButton = page.locator('[data-testid="sidebar-collapse-button"]');
|
||||||
|
if (await collapseButton.isVisible()) {
|
||||||
|
await collapseButton.click();
|
||||||
|
|
||||||
|
// Wait for sidebar transition (300ms) + buffer
|
||||||
|
await page.waitForTimeout(400);
|
||||||
|
|
||||||
|
// Get column width after collapse
|
||||||
|
const collapsedBox = await backlogColumn.boundingBox();
|
||||||
|
expect(collapsedBox).not.toBeNull();
|
||||||
|
|
||||||
|
if (initialBox && collapsedBox) {
|
||||||
|
// Column should be wider or same after sidebar collapse (more space available)
|
||||||
|
// Allow for small variations due to transitions
|
||||||
|
expect(collapsedBox.width).toBeGreaterThanOrEqual(initialBox.width - 5);
|
||||||
|
|
||||||
|
// Width should still be within bounds
|
||||||
|
expect(collapsedBox.width).toBeGreaterThanOrEqual(280);
|
||||||
|
expect(collapsedBox.width).toBeLessThanOrEqual(360);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify no horizontal scrollbar after collapse
|
||||||
|
const hasHorizontalScroll = await page.evaluate(() => {
|
||||||
|
const boardContainer = document.querySelector('[data-testid="board-view"]');
|
||||||
|
if (!boardContainer) return false;
|
||||||
|
return boardContainer.scrollWidth > boardContainer.clientWidth;
|
||||||
|
});
|
||||||
|
expect(hasHorizontalScroll).toBe(false);
|
||||||
|
}
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -70,6 +70,25 @@ export type ThinkingLevel = 'none' | 'low' | 'medium' | 'high' | 'ultrathink';
|
|||||||
/** ModelProvider - AI model provider for credentials and API key management */
|
/** ModelProvider - AI model provider for credentials and API key management */
|
||||||
export type ModelProvider = 'claude';
|
export type ModelProvider = 'claude';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* WindowBounds - Electron window position and size for persistence
|
||||||
|
*
|
||||||
|
* Stored in global settings to restore window state across sessions.
|
||||||
|
* Includes position (x, y), dimensions (width, height), and maximized state.
|
||||||
|
*/
|
||||||
|
export interface WindowBounds {
|
||||||
|
/** Window X position on screen */
|
||||||
|
x: number;
|
||||||
|
/** Window Y position on screen */
|
||||||
|
y: number;
|
||||||
|
/** Window width in pixels */
|
||||||
|
width: number;
|
||||||
|
/** Window height in pixels */
|
||||||
|
height: number;
|
||||||
|
/** Whether window was maximized when closed */
|
||||||
|
isMaximized: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* KeyboardShortcuts - User-configurable keyboard bindings for common actions
|
* KeyboardShortcuts - User-configurable keyboard bindings for common actions
|
||||||
*
|
*
|
||||||
@@ -272,6 +291,10 @@ export interface GlobalSettings {
|
|||||||
// Session Tracking
|
// Session Tracking
|
||||||
/** Maps project path -> last selected session ID in that project */
|
/** Maps project path -> last selected session ID in that project */
|
||||||
lastSelectedSessionByProject: Record<string, string>;
|
lastSelectedSessionByProject: Record<string, string>;
|
||||||
|
|
||||||
|
// Window State (Electron only)
|
||||||
|
/** Persisted window bounds for restoring position/size across sessions */
|
||||||
|
windowBounds?: WindowBounds;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
Reference in New Issue
Block a user