Rebuild of the kanban scaling logic, and adding constraints to window scaling logic for electron and web

This commit is contained in:
trueheads
2025-12-21 16:47:21 -06:00
parent ee9ccd03d6
commit 9beefd1ac3
10 changed files with 1918 additions and 266 deletions

View File

@@ -5,11 +5,11 @@
* Only native features (dialogs, shell) use IPC.
*/
import path from "path";
import { spawn, ChildProcess } from "child_process";
import fs from "fs";
import http, { Server } from "http";
import { app, BrowserWindow, ipcMain, dialog, shell } from "electron";
import path from 'path';
import { spawn, ChildProcess } from 'child_process';
import fs from 'fs';
import http, { Server } from 'http';
import { app, BrowserWindow, ipcMain, dialog, shell, screen } from 'electron';
// Development environment
const isDev = !app.isPackaged;
@@ -19,9 +19,9 @@ const VITE_DEV_SERVER_URL = process.env.VITE_DEV_SERVER_URL;
if (isDev) {
try {
// 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) {
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 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
*/
function getIconPath(): string | null {
let iconFile: string;
if (process.platform === "win32") {
iconFile = "icon.ico";
} else if (process.platform === "darwin") {
iconFile = "logo_larger.png";
if (process.platform === 'win32') {
iconFile = 'icon.ico';
} else if (process.platform === 'darwin') {
iconFile = 'logo_larger.png';
} else {
iconFile = "logo_larger.png";
iconFile = 'logo_larger.png';
}
const iconPath = isDev
? path.join(__dirname, "../public", iconFile)
: path.join(__dirname, "../dist/public", iconFile);
? path.join(__dirname, '../public', iconFile)
: path.join(__dirname, '../dist/public', iconFile);
if (!fs.existsSync(iconPath)) {
console.warn(`[Electron] Icon not found at: ${iconPath}`);
@@ -56,22 +89,136 @@ function getIconPath(): string | null {
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
*/
async function startStaticServer(): Promise<void> {
const staticPath = path.join(__dirname, "../dist");
const staticPath = path.join(__dirname, '../dist');
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("/")) {
filePath = path.join(filePath, "index.html");
if (filePath.endsWith('/')) {
filePath = path.join(filePath, 'index.html');
} else if (!path.extname(filePath)) {
// 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)) {
filePath = path.join(staticPath, "index.html");
filePath = path.join(staticPath, 'index.html');
} else if (fs.existsSync(possibleFile)) {
filePath = possibleFile;
}
@@ -79,35 +226,35 @@ async function startStaticServer(): Promise<void> {
fs.stat(filePath, (err, stats) => {
if (err || !stats?.isFile()) {
filePath = path.join(staticPath, "index.html");
filePath = path.join(staticPath, 'index.html');
}
fs.readFile(filePath, (error, content) => {
if (error) {
response.writeHead(500);
response.end("Server Error");
response.end('Server Error');
return;
}
const ext = path.extname(filePath);
const contentTypes: Record<string, string> = {
".html": "text/html",
".js": "application/javascript",
".css": "text/css",
".json": "application/json",
".png": "image/png",
".jpg": "image/jpeg",
".gif": "image/gif",
".svg": "image/svg+xml",
".ico": "image/x-icon",
".woff": "font/woff",
".woff2": "font/woff2",
".ttf": "font/ttf",
".eot": "application/vnd.ms-fontobject",
'.html': 'text/html',
'.js': 'application/javascript',
'.css': 'text/css',
'.json': 'application/json',
'.png': 'image/png',
'.jpg': 'image/jpeg',
'.gif': 'image/gif',
'.svg': 'image/svg+xml',
'.ico': 'image/x-icon',
'.woff': 'font/woff',
'.woff2': 'font/woff2',
'.ttf': 'font/ttf',
'.eot': 'application/vnd.ms-fontobject',
};
response.writeHead(200, {
"Content-Type": contentTypes[ext] || "application/octet-stream",
'Content-Type': contentTypes[ext] || 'application/octet-stream',
});
response.end(content);
});
@@ -116,12 +263,10 @@ async function startStaticServer(): Promise<void> {
return new Promise((resolve, reject) => {
staticServer!.listen(STATIC_PORT, () => {
console.log(
`[Electron] Static server running at http://localhost:${STATIC_PORT}`
);
console.log(`[Electron] Static server running at http://localhost:${STATIC_PORT}`);
resolve();
});
staticServer!.on("error", reject);
staticServer!.on('error', reject);
});
}
@@ -134,36 +279,31 @@ async function startServer(): Promise<void> {
let serverPath: string;
if (isDev) {
command = "node";
serverPath = path.join(__dirname, "../../server/src/index.ts");
command = 'node';
serverPath = path.join(__dirname, '../../server/src/index.ts');
const serverNodeModules = path.join(
__dirname,
"../../server/node_modules/tsx"
);
const rootNodeModules = path.join(__dirname, "../../../node_modules/tsx");
const serverNodeModules = path.join(__dirname, '../../server/node_modules/tsx');
const rootNodeModules = path.join(__dirname, '../../../node_modules/tsx');
let tsxCliPath: string;
if (fs.existsSync(path.join(serverNodeModules, "dist/cli.mjs"))) {
tsxCliPath = path.join(serverNodeModules, "dist/cli.mjs");
} else if (fs.existsSync(path.join(rootNodeModules, "dist/cli.mjs"))) {
tsxCliPath = path.join(rootNodeModules, "dist/cli.mjs");
if (fs.existsSync(path.join(serverNodeModules, 'dist/cli.mjs'))) {
tsxCliPath = path.join(serverNodeModules, 'dist/cli.mjs');
} else if (fs.existsSync(path.join(rootNodeModules, 'dist/cli.mjs'))) {
tsxCliPath = path.join(rootNodeModules, 'dist/cli.mjs');
} else {
try {
tsxCliPath = require.resolve("tsx/cli.mjs", {
paths: [path.join(__dirname, "../../server")],
tsxCliPath = require.resolve('tsx/cli.mjs', {
paths: [path.join(__dirname, '../../server')],
});
} catch {
throw new Error(
"Could not find tsx. Please run 'npm install' in the server directory."
);
throw new Error("Could not find tsx. Please run 'npm install' in the server directory.");
}
}
args = [tsxCliPath, "watch", serverPath];
args = [tsxCliPath, 'watch', serverPath];
} else {
command = "node";
serverPath = path.join(process.resourcesPath, "server", "index.js");
command = 'node';
serverPath = path.join(process.resourcesPath, 'server', 'index.js');
args = [serverPath];
if (!fs.existsSync(serverPath)) {
@@ -172,13 +312,13 @@ async function startServer(): Promise<void> {
}
const serverNodeModules = app.isPackaged
? path.join(process.resourcesPath, "server", "node_modules")
: path.join(__dirname, "../../server/node_modules");
? path.join(process.resourcesPath, 'server', 'node_modules')
: path.join(__dirname, '../../server/node_modules');
const env = {
...process.env,
PORT: SERVER_PORT.toString(),
DATA_DIR: app.getPath("userData"),
DATA_DIR: app.getPath('userData'),
NODE_PATH: serverNodeModules,
// Only set ALLOWED_ROOT_DIRECTORY if explicitly provided in environment
// 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] Server path:", serverPath);
console.log("[Electron] NODE_PATH:", serverNodeModules);
console.log('[Electron] Starting backend server...');
console.log('[Electron] Server path:', serverPath);
console.log('[Electron] NODE_PATH:', serverNodeModules);
serverProcess = spawn(command, args, {
cwd: path.dirname(serverPath),
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()}`);
});
serverProcess.stderr?.on("data", (data) => {
serverProcess.stderr?.on('data', (data) => {
console.error(`[Server Error] ${data.toString().trim()}`);
});
serverProcess.on("close", (code) => {
serverProcess.on('close', (code) => {
console.log(`[Server] Process exited with code ${code}`);
serverProcess = null;
});
serverProcess.on("error", (err) => {
serverProcess.on('error', (err) => {
console.error(`[Server] Failed to start server process:`, err);
serverProcess = null;
});
@@ -225,30 +365,27 @@ async function waitForServer(maxAttempts = 30): Promise<void> {
for (let i = 0; i < maxAttempts; i++) {
try {
await new Promise<void>((resolve, reject) => {
const req = http.get(
`http://localhost:${SERVER_PORT}/api/health`,
(res) => {
if (res.statusCode === 200) {
resolve();
} else {
reject(new Error(`Status: ${res.statusCode}`));
}
const req = http.get(`http://localhost:${SERVER_PORT}/api/health`, (res) => {
if (res.statusCode === 200) {
resolve();
} else {
reject(new Error(`Status: ${res.statusCode}`));
}
);
req.on("error", reject);
});
req.on('error', reject);
req.setTimeout(1000, () => {
req.destroy();
reject(new Error("Timeout"));
reject(new Error('Timeout'));
});
});
console.log("[Electron] Server is ready");
console.log('[Electron] Server is ready');
return;
} catch {
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 {
const iconPath = getIconPath();
// Load and validate saved window bounds
const savedBounds = loadWindowBounds();
const validBounds = savedBounds ? validateBounds(savedBounds) : null;
const windowOptions: Electron.BrowserWindowConstructorOptions = {
width: 1600,
height: 950,
minWidth: 1280,
minHeight: 768,
width: validBounds?.width ?? DEFAULT_WIDTH,
height: validBounds?.height ?? DEFAULT_HEIGHT,
x: validBounds?.x,
y: validBounds?.y,
minWidth: MIN_WIDTH_EXPANDED, // 1500px - ensures kanban columns fit with sidebar
minHeight: MIN_HEIGHT,
webPreferences: {
preload: path.join(__dirname, "preload.js"),
preload: path.join(__dirname, 'preload.js'),
contextIsolation: true,
nodeIntegration: false,
},
titleBarStyle: "hiddenInset",
backgroundColor: "#0a0a0a",
titleBarStyle: 'hiddenInset',
backgroundColor: '#0a0a0a',
};
if (iconPath) {
@@ -276,6 +420,11 @@ function createWindow(): void {
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
if (VITE_DEV_SERVER_URL) {
mainWindow.loadURL(VITE_DEV_SERVER_URL);
@@ -286,17 +435,42 @@ function createWindow(): void {
mainWindow.loadURL(`http://localhost:${STATIC_PORT}`);
}
if (isDev && process.env.OPEN_DEVTOOLS === "true") {
if (isDev && process.env.OPEN_DEVTOOLS === 'true') {
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.on('resized', () => {
scheduleSaveWindowBounds();
});
mainWindow.on('moved', () => {
scheduleSaveWindowBounds();
});
mainWindow.webContents.setWindowOpenHandler(({ url }) => {
shell.openExternal(url);
return { action: "deny" };
return { action: 'deny' };
});
}
@@ -304,28 +478,22 @@ function createWindow(): void {
app.whenReady().then(async () => {
// Ensure userData path is consistent across dev/prod so files land in Automaker dir
try {
const desiredUserDataPath = path.join(app.getPath("appData"), "Automaker");
if (app.getPath("userData") !== desiredUserDataPath) {
app.setPath("userData", desiredUserDataPath);
console.log("[Electron] userData path set to:", desiredUserDataPath);
const desiredUserDataPath = path.join(app.getPath('appData'), 'Automaker');
if (app.getPath('userData') !== desiredUserDataPath) {
app.setPath('userData', desiredUserDataPath);
console.log('[Electron] userData path set to:', desiredUserDataPath);
}
} catch (error) {
console.warn(
"[Electron] Failed to set userData path:",
(error as Error).message
);
console.warn('[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();
if (iconPath) {
try {
app.dock.setIcon(iconPath);
} catch (error) {
console.warn(
"[Electron] Failed to set dock icon:",
(error as Error).message
);
console.warn('[Electron] Failed to set dock icon:', (error as Error).message);
}
}
}
@@ -342,32 +510,32 @@ app.whenReady().then(async () => {
// Create window
createWindow();
} catch (error) {
console.error("[Electron] Failed to start:", error);
console.error('[Electron] Failed to start:', error);
app.quit();
}
app.on("activate", () => {
app.on('activate', () => {
if (BrowserWindow.getAllWindows().length === 0) {
createWindow();
}
});
});
app.on("window-all-closed", () => {
if (process.platform !== "darwin") {
app.on('window-all-closed', () => {
if (process.platform !== 'darwin') {
app.quit();
}
});
app.on("before-quit", () => {
app.on('before-quit', () => {
if (serverProcess) {
console.log("[Electron] Stopping server...");
console.log('[Electron] Stopping server...');
serverProcess.kill();
serverProcess = null;
}
if (staticServer) {
console.log("[Electron] Stopping static server...");
console.log('[Electron] Stopping static server...');
staticServer.close();
staticServer = null;
}
@@ -378,28 +546,28 @@ app.on("before-quit", () => {
// ============================================
// Native file dialogs
ipcMain.handle("dialog:openDirectory", async () => {
ipcMain.handle('dialog:openDirectory', async () => {
if (!mainWindow) {
return { canceled: true, filePaths: [] };
}
const result = await dialog.showOpenDialog(mainWindow, {
properties: ["openDirectory", "createDirectory"],
properties: ['openDirectory', 'createDirectory'],
});
return result;
});
ipcMain.handle("dialog:openFile", async (_, options = {}) => {
ipcMain.handle('dialog:openFile', async (_, options = {}) => {
if (!mainWindow) {
return { canceled: true, filePaths: [] };
}
const result = await dialog.showOpenDialog(mainWindow, {
properties: ["openFile"],
properties: ['openFile'],
...options,
});
return result;
});
ipcMain.handle("dialog:saveFile", async (_, options = {}) => {
ipcMain.handle('dialog:saveFile', async (_, options = {}) => {
if (!mainWindow) {
return { canceled: true, filePath: undefined };
}
@@ -408,7 +576,7 @@ ipcMain.handle("dialog:saveFile", async (_, options = {}) => {
});
// Shell operations
ipcMain.handle("shell:openExternal", async (_, url: string) => {
ipcMain.handle('shell:openExternal', async (_, url: string) => {
try {
await shell.openExternal(url);
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 {
await shell.openPath(filePath);
return { success: true };
@@ -427,27 +595,38 @@ ipcMain.handle("shell:openPath", async (_, filePath: string) => {
});
// App info
ipcMain.handle(
"app:getPath",
async (_, name: Parameters<typeof app.getPath>[0]) => {
return app.getPath(name);
}
);
ipcMain.handle('app:getPath', async (_, name: Parameters<typeof app.getPath>[0]) => {
return app.getPath(name);
});
ipcMain.handle("app:getVersion", async () => {
ipcMain.handle('app:getVersion', async () => {
return app.getVersion();
});
ipcMain.handle("app:isPackaged", async () => {
ipcMain.handle('app:isPackaged', async () => {
return app.isPackaged;
});
// Ping - for connection check
ipcMain.handle("ping", async () => {
return "pong";
ipcMain.handle('ping', async () => {
return 'pong';
});
// Get server URL for HTTP client
ipcMain.handle("server:getUrl", async () => {
ipcMain.handle('server:getUrl', async () => {
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);
}
});