Fix terminal rendering, persistence, and link handling (#1215)
* auto-claude: subtask-1-1 - Add fit trigger after drag-drop completes in TerminalGrid When terminals are reordered via drag-drop, the xterm instances need to be refitted to their containers to prevent black screens. This change: - Dispatches a 'terminal-refit-all' custom event from TerminalGrid after terminal reordering completes (with 50ms delay to allow DOM update) - Adds event listener in useXterm that triggers fit on all terminals when the event is received Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * auto-claude: subtask-1-2 - Pass fit callback from useXterm to Terminal component - Export TerminalHandle interface from Terminal component with fit() method - Use forwardRef and useImperativeHandle to expose fit callback to parent components - Export SortableTerminalWrapperHandle interface with fit() method - Forward fit callback through SortableTerminalWrapper to enable external triggering - This allows parent components to trigger terminal resize after container changes Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * auto-claude: subtask-1-3 - Add fit trigger on expansion state change Add useEffect that calls fit() when isExpanded prop changes. This ensures the terminal content properly resizes to fill the container when a terminal is expanded or collapsed. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * auto-claude: subtask-2-1 - Add displayOrder field to TerminalSession type def * auto-claude: subtask-2-2 - Add displayOrder field to renderer Terminal interface - Added displayOrder?: number to Terminal interface for tab persistence - Updated addTerminal to set displayOrder based on current array length - Updated addRestoredTerminal to restore displayOrder from session - Updated addExternalTerminal to set displayOrder for new terminals - Updated reorderTerminals to update displayOrder values after drag-drop - Updated restoreTerminalSessions to sort sessions by displayOrder Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * auto-claude: subtask-2-3 - Persist displayOrder when saving terminal sessions Add displayOrder field to TerminalSession interface in the main process terminal-session-store.ts. This field stores the UI position for ordering terminals after drag-drop, enabling order persistence across app restarts. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * auto-claude: subtask-2-4 - Save order after drag-drop reorder and restore on app startup Implements terminal display order persistence: - Added TERMINAL_UPDATE_DISPLAY_ORDERS IPC channel - Added updateDisplayOrders method to TerminalSessionStore - Added session-handler and terminal-manager wrapper functions - Added IPC handler in terminal-handlers.ts - Added ElectronAPI type and preload API method - Updated TerminalGrid.tsx to persist order after drag-drop reorder - Added browser mock for updateTerminalDisplayOrders Now when terminals are reordered via drag-drop, the new order is persisted to disk and restored when the app restarts. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * auto-claude: subtask-3-1 - Add WebLinksAddon callback to open links via openExternal IPC * fix(main): add error handling to setWindowOpenHandler shell.openExternal The setWindowOpenHandler calls shell.openExternal without handling its promise, causing unhandled rejection errors when the OS cannot open a URL (e.g., no registered handler for the protocol). While PR #1215 fixes terminal link clicks by routing them through IPC with proper error handling, this setWindowOpenHandler is still used as a fallback for any other window.open() calls (e.g., from third-party libraries). This change adds a .catch() handler to gracefully log failures instead of causing Sentry errors. Fixes: Sentry error "No application found to open URL" in production v2.7.4 Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * fix(terminal): resolve PR review findings for persistence and security - Preserve displayOrder when updating existing sessions to prevent tab order loss during periodic saves - Sort sessions by displayOrder in handleRestoreFromDate to maintain user's custom tab ordering when restoring from history - Add URL scheme allowlist (http, https, mailto) in setWindowOpenHandler for security hardening against malicious URL schemes - Extract 50ms DOM update delay to TERMINAL_DOM_UPDATE_DELAY_MS constant - Add error handling for IPC persistence calls in TerminalGrid - Add .catch() handler to WebLinksAddon openExternal promise --------- Co-authored-by: Test User <test@example.com> Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -194,9 +194,24 @@ function createWindow(): void {
|
||||
mainWindow?.show();
|
||||
});
|
||||
|
||||
// Handle external links
|
||||
// Handle external links with URL scheme allowlist for security
|
||||
// Note: Terminal links now use IPC via WebLinksAddon callback, but this handler
|
||||
// catches any other window.open() calls (e.g., from third-party libraries)
|
||||
const ALLOWED_URL_SCHEMES = ['http:', 'https:', 'mailto:'];
|
||||
mainWindow.webContents.setWindowOpenHandler((details) => {
|
||||
shell.openExternal(details.url);
|
||||
try {
|
||||
const url = new URL(details.url);
|
||||
if (!ALLOWED_URL_SCHEMES.includes(url.protocol)) {
|
||||
console.warn('[main] Blocked URL with disallowed scheme:', details.url);
|
||||
return { action: 'deny' };
|
||||
}
|
||||
} catch {
|
||||
console.warn('[main] Blocked invalid URL:', details.url);
|
||||
return { action: 'deny' };
|
||||
}
|
||||
shell.openExternal(details.url).catch((error) => {
|
||||
console.warn('[main] Failed to open external URL:', details.url, error);
|
||||
});
|
||||
return { action: 'deny' };
|
||||
});
|
||||
|
||||
|
||||
@@ -661,6 +661,26 @@ export function registerTerminalHandlers(
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// Update terminal display orders after drag-drop reorder
|
||||
ipcMain.handle(
|
||||
IPC_CHANNELS.TERMINAL_UPDATE_DISPLAY_ORDERS,
|
||||
async (
|
||||
_,
|
||||
projectPath: string,
|
||||
orders: Array<{ terminalId: string; displayOrder: number }>
|
||||
): Promise<IPCResult> => {
|
||||
try {
|
||||
terminalManager.updateDisplayOrders(projectPath, orders);
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Failed to update display orders'
|
||||
};
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -18,6 +18,8 @@ export interface TerminalSession {
|
||||
lastActiveAt: string; // ISO timestamp
|
||||
/** Associated worktree configuration (validated on restore) */
|
||||
worktreeConfig?: TerminalWorktreeConfig;
|
||||
/** UI display position for ordering terminals after drag-drop */
|
||||
displayOrder?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -256,11 +258,16 @@ export class TerminalSessionStore {
|
||||
// Update existing or add new
|
||||
const existingIndex = todaySessions[projectPath].findIndex(s => s.id === session.id);
|
||||
if (existingIndex >= 0) {
|
||||
// Preserve displayOrder from existing session if not provided in incoming session
|
||||
// This prevents periodic saves (which don't include displayOrder) from losing tab order
|
||||
const existingSession = todaySessions[projectPath][existingIndex];
|
||||
todaySessions[projectPath][existingIndex] = {
|
||||
...session,
|
||||
// Limit output buffer size
|
||||
outputBuffer: session.outputBuffer.slice(-MAX_OUTPUT_BUFFER),
|
||||
lastActiveAt: new Date().toISOString()
|
||||
lastActiveAt: new Date().toISOString(),
|
||||
// Preserve existing displayOrder if incoming session doesn't have it
|
||||
displayOrder: session.displayOrder ?? existingSession.displayOrder,
|
||||
};
|
||||
} else {
|
||||
todaySessions[projectPath].push({
|
||||
@@ -523,6 +530,30 @@ export class TerminalSessionStore {
|
||||
this.save();
|
||||
}
|
||||
|
||||
/**
|
||||
* Update display orders for multiple terminals (after drag-drop reorder).
|
||||
* This updates the displayOrder property for matching sessions in today's bucket.
|
||||
*/
|
||||
updateDisplayOrders(projectPath: string, orders: Array<{ terminalId: string; displayOrder: number }>): void {
|
||||
const todaySessions = this.getTodaysSessions();
|
||||
const sessions = todaySessions[projectPath];
|
||||
if (!sessions) return;
|
||||
|
||||
let hasChanges = false;
|
||||
for (const { terminalId, displayOrder } of orders) {
|
||||
const session = sessions.find(s => s.id === terminalId);
|
||||
if (session && session.displayOrder !== displayOrder) {
|
||||
session.displayOrder = displayOrder;
|
||||
session.lastActiveAt = new Date().toISOString();
|
||||
hasChanges = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (hasChanges) {
|
||||
this.save();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Save a terminal session asynchronously (non-blocking)
|
||||
*
|
||||
|
||||
@@ -282,6 +282,17 @@ export function getSessionsForDate(date: string, projectPath: string): TerminalS
|
||||
return store.getSessionsForDate(date, projectPath);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update display orders for terminals after drag-drop reorder
|
||||
*/
|
||||
export function updateDisplayOrders(
|
||||
projectPath: string,
|
||||
orders: Array<{ terminalId: string; displayOrder: number }>
|
||||
): void {
|
||||
const store = getTerminalSessionStore();
|
||||
store.updateDisplayOrders(projectPath, orders);
|
||||
}
|
||||
|
||||
/**
|
||||
* Attempt to capture Claude session ID by polling the session directory.
|
||||
* Uses the claim mechanism to prevent race conditions when multiple terminals
|
||||
|
||||
@@ -292,6 +292,16 @@ export class TerminalManager {
|
||||
return SessionHandler.getSessionsForDate(date, projectPath);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update display orders for terminals after drag-drop reorder
|
||||
*/
|
||||
updateDisplayOrders(
|
||||
projectPath: string,
|
||||
orders: Array<{ terminalId: string; displayOrder: number }>
|
||||
): void {
|
||||
SessionHandler.updateDisplayOrders(projectPath, orders);
|
||||
}
|
||||
|
||||
/**
|
||||
* Restore all sessions from a specific date
|
||||
*/
|
||||
|
||||
@@ -60,6 +60,10 @@ export interface TerminalAPI {
|
||||
rows?: number
|
||||
) => Promise<IPCResult<import('../../shared/types').SessionDateRestoreResult>>;
|
||||
checkTerminalPtyAlive: (terminalId: string) => Promise<IPCResult<{ alive: boolean }>>;
|
||||
updateTerminalDisplayOrders: (
|
||||
projectPath: string,
|
||||
orders: Array<{ terminalId: string; displayOrder: number }>
|
||||
) => Promise<IPCResult>;
|
||||
|
||||
// Terminal Worktree Operations (isolated development)
|
||||
createTerminalWorktree: (request: CreateTerminalWorktreeRequest) => Promise<TerminalWorktreeResult>;
|
||||
@@ -172,6 +176,12 @@ export const createTerminalAPI = (): TerminalAPI => ({
|
||||
checkTerminalPtyAlive: (terminalId: string): Promise<IPCResult<{ alive: boolean }>> =>
|
||||
ipcRenderer.invoke(IPC_CHANNELS.TERMINAL_CHECK_PTY_ALIVE, terminalId),
|
||||
|
||||
updateTerminalDisplayOrders: (
|
||||
projectPath: string,
|
||||
orders: Array<{ terminalId: string; displayOrder: number }>
|
||||
): Promise<IPCResult> =>
|
||||
ipcRenderer.invoke(IPC_CHANNELS.TERMINAL_UPDATE_DISPLAY_ORDERS, projectPath, orders),
|
||||
|
||||
// Terminal Worktree Operations (isolated development)
|
||||
createTerminalWorktree: (request: CreateTerminalWorktreeRequest): Promise<TerminalWorktreeResult> =>
|
||||
ipcRenderer.invoke(IPC_CHANNELS.TERMINAL_WORKTREE_CREATE, request),
|
||||
|
||||
@@ -1,10 +1,19 @@
|
||||
import React from 'react';
|
||||
import React, { useRef, forwardRef, useImperativeHandle } from 'react';
|
||||
import { useSortable } from '@dnd-kit/sortable';
|
||||
import { CSS } from '@dnd-kit/utilities';
|
||||
import type { Task } from '../../shared/types';
|
||||
import { Terminal } from './Terminal';
|
||||
import { Terminal, type TerminalHandle } from './Terminal';
|
||||
import { cn } from '../lib/utils';
|
||||
|
||||
/**
|
||||
* Handle interface exposed by SortableTerminalWrapper for external control.
|
||||
* Allows parent components to trigger terminal operations like fit.
|
||||
*/
|
||||
export interface SortableTerminalWrapperHandle {
|
||||
/** Refit the terminal to its container size */
|
||||
fit: () => void;
|
||||
}
|
||||
|
||||
interface SortableTerminalWrapperProps {
|
||||
id: string;
|
||||
cwd?: string;
|
||||
@@ -19,65 +28,76 @@ interface SortableTerminalWrapperProps {
|
||||
onToggleExpand?: () => void;
|
||||
}
|
||||
|
||||
export function SortableTerminalWrapper({
|
||||
id,
|
||||
cwd,
|
||||
projectPath,
|
||||
isActive,
|
||||
onClose,
|
||||
onActivate,
|
||||
tasks,
|
||||
onNewTaskClick,
|
||||
terminalCount,
|
||||
isExpanded,
|
||||
onToggleExpand,
|
||||
}: SortableTerminalWrapperProps) {
|
||||
const {
|
||||
attributes,
|
||||
listeners,
|
||||
setNodeRef,
|
||||
transform,
|
||||
transition,
|
||||
isDragging,
|
||||
} = useSortable({
|
||||
export const SortableTerminalWrapper = forwardRef<SortableTerminalWrapperHandle, SortableTerminalWrapperProps>(
|
||||
function SortableTerminalWrapper({
|
||||
id,
|
||||
data: {
|
||||
type: 'terminal-panel',
|
||||
terminalId: id,
|
||||
},
|
||||
});
|
||||
cwd,
|
||||
projectPath,
|
||||
isActive,
|
||||
onClose,
|
||||
onActivate,
|
||||
tasks,
|
||||
onNewTaskClick,
|
||||
terminalCount,
|
||||
isExpanded,
|
||||
onToggleExpand,
|
||||
}, ref) {
|
||||
const terminalRef = useRef<TerminalHandle>(null);
|
||||
|
||||
const style = {
|
||||
transform: CSS.Transform.toString(transform),
|
||||
transition,
|
||||
zIndex: isDragging ? 50 : undefined,
|
||||
};
|
||||
const {
|
||||
attributes,
|
||||
listeners,
|
||||
setNodeRef,
|
||||
transform,
|
||||
transition,
|
||||
isDragging,
|
||||
} = useSortable({
|
||||
id,
|
||||
data: {
|
||||
type: 'terminal-panel',
|
||||
terminalId: id,
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={setNodeRef}
|
||||
style={style}
|
||||
className={cn(
|
||||
'h-full',
|
||||
isDragging && 'opacity-50'
|
||||
)}
|
||||
{...attributes}
|
||||
>
|
||||
<Terminal
|
||||
id={id}
|
||||
cwd={cwd}
|
||||
projectPath={projectPath}
|
||||
isActive={isActive}
|
||||
onClose={onClose}
|
||||
onActivate={onActivate}
|
||||
tasks={tasks}
|
||||
onNewTaskClick={onNewTaskClick}
|
||||
terminalCount={terminalCount}
|
||||
dragHandleListeners={listeners}
|
||||
isDragging={isDragging}
|
||||
isExpanded={isExpanded}
|
||||
onToggleExpand={onToggleExpand}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
// Expose fit method to parent components via ref
|
||||
// This allows external triggering of terminal resize (e.g., after drag-drop reorder)
|
||||
useImperativeHandle(ref, () => ({
|
||||
fit: () => terminalRef.current?.fit(),
|
||||
}), []);
|
||||
|
||||
const style = {
|
||||
transform: CSS.Transform.toString(transform),
|
||||
transition,
|
||||
zIndex: isDragging ? 50 : undefined,
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={setNodeRef}
|
||||
style={style}
|
||||
className={cn(
|
||||
'h-full',
|
||||
isDragging && 'opacity-50'
|
||||
)}
|
||||
{...attributes}
|
||||
>
|
||||
<Terminal
|
||||
ref={terminalRef}
|
||||
id={id}
|
||||
cwd={cwd}
|
||||
projectPath={projectPath}
|
||||
isActive={isActive}
|
||||
onClose={onClose}
|
||||
onActivate={onActivate}
|
||||
tasks={tasks}
|
||||
onNewTaskClick={onNewTaskClick}
|
||||
terminalCount={terminalCount}
|
||||
dragHandleListeners={listeners}
|
||||
isDragging={isDragging}
|
||||
isExpanded={isExpanded}
|
||||
onToggleExpand={onToggleExpand}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useRef, useCallback, useState, useMemo } from 'react';
|
||||
import { useEffect, useRef, useCallback, useState, useMemo, forwardRef, useImperativeHandle } from 'react';
|
||||
import { useDroppable, useDndContext } from '@dnd-kit/core';
|
||||
import '@xterm/xterm/css/xterm.css';
|
||||
import { FileDown } from 'lucide-react';
|
||||
@@ -8,6 +8,7 @@ import { useSettingsStore } from '../stores/settings-store';
|
||||
import { useToast } from '../hooks/use-toast';
|
||||
import type { TerminalProps } from './terminal/types';
|
||||
import type { TerminalWorktreeConfig } from '../../shared/types';
|
||||
import { TERMINAL_DOM_UPDATE_DELAY_MS } from '../../shared/constants';
|
||||
import { TerminalHeader } from './terminal/TerminalHeader';
|
||||
import { CreateWorktreeDialog } from './terminal/CreateWorktreeDialog';
|
||||
import { useXterm } from './terminal/useXterm';
|
||||
@@ -20,7 +21,17 @@ import { useTerminalFileDrop } from './terminal/useTerminalFileDrop';
|
||||
const MIN_COLS = 10;
|
||||
const MIN_ROWS = 3;
|
||||
|
||||
export function Terminal({
|
||||
/**
|
||||
* Handle interface exposed by Terminal component for external control.
|
||||
* Used by parent components (e.g., SortableTerminalWrapper) to trigger operations
|
||||
* like refitting the terminal after container size changes.
|
||||
*/
|
||||
export interface TerminalHandle {
|
||||
/** Refit the terminal to its container size */
|
||||
fit: () => void;
|
||||
}
|
||||
|
||||
export const Terminal = forwardRef<TerminalHandle, TerminalProps>(function Terminal({
|
||||
id,
|
||||
cwd,
|
||||
projectPath,
|
||||
@@ -34,7 +45,7 @@ export function Terminal({
|
||||
isDragging,
|
||||
isExpanded,
|
||||
onToggleExpand,
|
||||
}: TerminalProps) {
|
||||
}, ref) {
|
||||
const isMountedRef = useRef(true);
|
||||
const isCreatedRef = useRef(false);
|
||||
// Track deliberate terminal recreation (e.g., worktree switching)
|
||||
@@ -103,6 +114,7 @@ export function Terminal({
|
||||
const {
|
||||
terminalRef,
|
||||
xtermRef: _xtermRef,
|
||||
fit,
|
||||
write: _write, // Output now handled by useGlobalTerminalListeners
|
||||
writeln,
|
||||
focus,
|
||||
@@ -120,6 +132,12 @@ export function Terminal({
|
||||
onDimensionsReady: handleDimensionsReady,
|
||||
});
|
||||
|
||||
// Expose fit method to parent components via ref
|
||||
// This allows external triggering of terminal resize (e.g., after drag-drop reorder)
|
||||
useImperativeHandle(ref, () => ({
|
||||
fit,
|
||||
}), [fit]);
|
||||
|
||||
// Use ready dimensions for PTY creation (wait until xterm has measured)
|
||||
// This prevents creating PTY with default 80x24 when container is smaller
|
||||
const ptyDimensions = useMemo(() => {
|
||||
@@ -171,6 +189,14 @@ export function Terminal({
|
||||
}
|
||||
}, [isActive, focus]);
|
||||
|
||||
// Refit terminal when expansion state changes
|
||||
useEffect(() => {
|
||||
const timeoutId = setTimeout(() => {
|
||||
fit();
|
||||
}, TERMINAL_DOM_UPDATE_DELAY_MS);
|
||||
return () => clearTimeout(timeoutId);
|
||||
}, [isExpanded, fit]);
|
||||
|
||||
// Trigger deferred Claude resume when terminal becomes active
|
||||
// This ensures Claude sessions are only resumed when the user actually views the terminal,
|
||||
// preventing all terminals from resuming simultaneously on app startup (which can crash the app)
|
||||
@@ -426,4 +452,4 @@ Please confirm you're ready by saying: I'm ready to work on ${selectedTask.title
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -35,6 +35,7 @@ import { cn } from '../lib/utils';
|
||||
import { useTerminalStore } from '../stores/terminal-store';
|
||||
import { useTaskStore } from '../stores/task-store';
|
||||
import { useFileExplorerStore } from '../stores/file-explorer-store';
|
||||
import { TERMINAL_DOM_UPDATE_DELAY_MS } from '../../shared/constants';
|
||||
import type { SessionDateInfo } from '../../shared/types';
|
||||
|
||||
interface TerminalGridProps {
|
||||
@@ -148,11 +149,17 @@ export function TerminalGrid({ projectPath, onNewTaskClick, isActive = false }:
|
||||
if (result.success && result.data) {
|
||||
console.warn(`[TerminalGrid] Main process restored ${result.data.restored} sessions from ${date}`);
|
||||
|
||||
// Sort sessions by displayOrder before restoring to preserve user's tab ordering
|
||||
const sortedSessions = [...sessionsToRestore].sort((a, b) => {
|
||||
const orderA = a.displayOrder ?? Number.MAX_SAFE_INTEGER;
|
||||
const orderB = b.displayOrder ?? Number.MAX_SAFE_INTEGER;
|
||||
return orderA - orderB;
|
||||
});
|
||||
|
||||
// Add each successfully restored session to the renderer's terminal store
|
||||
for (const sessionResult of result.data.sessions) {
|
||||
if (sessionResult.success) {
|
||||
// Find the full session data
|
||||
const fullSession = sessionsToRestore.find(s => s.id === sessionResult.id);
|
||||
const fullSession = sortedSessions.find(s => s.id === sessionResult.id);
|
||||
if (fullSession) {
|
||||
console.warn(`[TerminalGrid] Adding restored terminal to store: ${fullSession.id}`);
|
||||
addRestoredTerminal(fullSession);
|
||||
@@ -292,6 +299,29 @@ export function TerminalGrid({ projectPath, onNewTaskClick, isActive = false }:
|
||||
|
||||
if (activeId !== overId && terminals.some(t => t.id === overId)) {
|
||||
reorderTerminals(activeId, overId);
|
||||
|
||||
// Persist the new order to disk so it survives app restarts
|
||||
// Use a microtask to ensure the store has updated before we read the new order
|
||||
if (projectPath) {
|
||||
queueMicrotask(async () => {
|
||||
const updatedTerminals = useTerminalStore.getState().terminals;
|
||||
const orders = updatedTerminals
|
||||
.filter(t => t.projectPath === projectPath || !t.projectPath)
|
||||
.map(t => ({ terminalId: t.id, displayOrder: t.displayOrder ?? 0 }));
|
||||
try {
|
||||
const result = await window.electronAPI.updateTerminalDisplayOrders(projectPath, orders);
|
||||
if (!result.success) {
|
||||
console.warn('[TerminalGrid] Failed to persist terminal order:', result.error);
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('[TerminalGrid] Failed to persist terminal order:', error);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
setTimeout(() => {
|
||||
window.dispatchEvent(new CustomEvent('terminal-refit-all'));
|
||||
}, TERMINAL_DOM_UPDATE_DELAY_MS);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -82,7 +82,11 @@ export function useXterm({ terminalId, onCommandEnter, onResize, onDimensionsRea
|
||||
});
|
||||
|
||||
const fitAddon = new FitAddon();
|
||||
const webLinksAddon = new WebLinksAddon();
|
||||
const webLinksAddon = new WebLinksAddon((_event, uri) => {
|
||||
window.electronAPI?.openExternal?.(uri).catch((error) => {
|
||||
console.warn('[useXterm] Failed to open URL:', uri, error);
|
||||
});
|
||||
});
|
||||
const serializeAddon = new SerializeAddon();
|
||||
|
||||
xterm.loadAddon(fitAddon);
|
||||
@@ -341,6 +345,24 @@ export function useXterm({ terminalId, onCommandEnter, onResize, onDimensionsRea
|
||||
}
|
||||
}, [onDimensionsReady]);
|
||||
|
||||
// Listen for terminal refit events (triggered after drag-drop reorder)
|
||||
useEffect(() => {
|
||||
const handleRefitAll = () => {
|
||||
if (fitAddonRef.current && xtermRef.current && terminalRef.current) {
|
||||
const rect = terminalRef.current.getBoundingClientRect();
|
||||
if (rect.width > 0 && rect.height > 0) {
|
||||
fitAddonRef.current.fit();
|
||||
const cols = xtermRef.current.cols;
|
||||
const rows = xtermRef.current.rows;
|
||||
setDimensions({ cols, rows });
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('terminal-refit-all', handleRefitAll);
|
||||
return () => window.removeEventListener('terminal-refit-all', handleRefitAll);
|
||||
}, []);
|
||||
|
||||
const fit = useCallback(() => {
|
||||
if (fitAddonRef.current && xtermRef.current) {
|
||||
fitAddonRef.current.fit();
|
||||
|
||||
@@ -88,6 +88,10 @@ export const terminalMock = {
|
||||
data: { alive: false }
|
||||
}),
|
||||
|
||||
updateTerminalDisplayOrders: async () => ({
|
||||
success: true
|
||||
}),
|
||||
|
||||
// Terminal Event Listeners (no-op in browser)
|
||||
onTerminalOutput: () => () => {},
|
||||
onTerminalExit: () => () => {},
|
||||
|
||||
@@ -93,6 +93,7 @@ export interface Terminal {
|
||||
worktreeConfig?: TerminalWorktreeConfig; // Associated worktree for isolated development
|
||||
isClaudeBusy?: boolean; // Whether Claude Code is actively processing (for visual indicator)
|
||||
pendingClaudeResume?: boolean; // Whether this terminal has a pending Claude resume (deferred until tab activated)
|
||||
displayOrder?: number; // Display order for tab persistence (lower = further left)
|
||||
}
|
||||
|
||||
interface TerminalLayout {
|
||||
@@ -159,6 +160,7 @@ export const useTerminalStore = create<TerminalState>((set, get) => ({
|
||||
isClaudeMode: false,
|
||||
// outputBuffer removed - managed by terminalBufferManager
|
||||
projectPath,
|
||||
displayOrder: state.terminals.length, // New terminals appear at the end
|
||||
};
|
||||
|
||||
set((state) => ({
|
||||
@@ -193,6 +195,8 @@ export const useTerminalStore = create<TerminalState>((set, get) => ({
|
||||
projectPath: session.projectPath,
|
||||
// Worktree config is validated in main process before restore
|
||||
worktreeConfig: session.worktreeConfig,
|
||||
// Restore displayOrder for tab position persistence (falls back to end if not set)
|
||||
displayOrder: session.displayOrder ?? state.terminals.length,
|
||||
};
|
||||
|
||||
// Restore buffer to buffer manager
|
||||
@@ -234,6 +238,7 @@ export const useTerminalStore = create<TerminalState>((set, get) => ({
|
||||
createdAt: new Date(),
|
||||
isClaudeMode: false,
|
||||
projectPath,
|
||||
displayOrder: state.terminals.length, // New terminals appear at the end
|
||||
};
|
||||
|
||||
set((state) => ({
|
||||
@@ -355,8 +360,15 @@ export const useTerminalStore = create<TerminalState>((set, get) => ({
|
||||
return state;
|
||||
}
|
||||
|
||||
// Reorder terminals and update displayOrder values based on new positions
|
||||
const reorderedTerminals = arrayMove(state.terminals, oldIndex, newIndex);
|
||||
const terminalsWithOrder = reorderedTerminals.map((terminal, index) => ({
|
||||
...terminal,
|
||||
displayOrder: index,
|
||||
}));
|
||||
|
||||
return {
|
||||
terminals: arrayMove(state.terminals, oldIndex, newIndex),
|
||||
terminals: terminalsWithOrder,
|
||||
};
|
||||
});
|
||||
},
|
||||
@@ -460,8 +472,16 @@ export async function restoreTerminalSessions(projectPath: string): Promise<void
|
||||
return;
|
||||
}
|
||||
|
||||
// Add terminals to the store (they'll be created in the TerminalGrid component)
|
||||
for (const session of result.data) {
|
||||
// Sort sessions by displayOrder before restoring (lower = further left)
|
||||
// Sessions without displayOrder are placed at the end
|
||||
const sortedSessions = [...result.data].sort((a, b) => {
|
||||
const orderA = a.displayOrder ?? Number.MAX_SAFE_INTEGER;
|
||||
const orderB = b.displayOrder ?? Number.MAX_SAFE_INTEGER;
|
||||
return orderA - orderB;
|
||||
});
|
||||
|
||||
// Add terminals to the store in correct order (they'll be created in the TerminalGrid component)
|
||||
for (const session of sortedSessions) {
|
||||
store.addRestoredTerminal(session);
|
||||
}
|
||||
|
||||
|
||||
@@ -3,6 +3,13 @@
|
||||
* Default settings, file paths, and project structure
|
||||
*/
|
||||
|
||||
// ============================================
|
||||
// Terminal Timing Constants
|
||||
// ============================================
|
||||
|
||||
/** Delay for DOM updates before terminal operations (refit, resize) */
|
||||
export const TERMINAL_DOM_UPDATE_DELAY_MS = 50;
|
||||
|
||||
// ============================================
|
||||
// UI Scale Constants
|
||||
// ============================================
|
||||
|
||||
@@ -78,6 +78,7 @@ export const IPC_CHANNELS = {
|
||||
TERMINAL_GET_SESSIONS_FOR_DATE: 'terminal:getSessionsForDate',
|
||||
TERMINAL_RESTORE_FROM_DATE: 'terminal:restoreFromDate',
|
||||
TERMINAL_CHECK_PTY_ALIVE: 'terminal:checkPtyAlive',
|
||||
TERMINAL_UPDATE_DISPLAY_ORDERS: 'terminal:updateDisplayOrders', // Persist terminal display order after drag-drop reorder
|
||||
|
||||
// Terminal worktree operations (isolated development in worktrees)
|
||||
TERMINAL_WORKTREE_CREATE: 'terminal:worktreeCreate',
|
||||
|
||||
@@ -211,6 +211,10 @@ export interface ElectronAPI {
|
||||
restoreTerminalSessionsFromDate: (date: string, projectPath: string, cols?: number, rows?: number) => Promise<IPCResult<SessionDateRestoreResult>>;
|
||||
saveTerminalBuffer: (terminalId: string, serialized: string) => Promise<void>;
|
||||
checkTerminalPtyAlive: (terminalId: string) => Promise<IPCResult<{ alive: boolean }>>;
|
||||
updateTerminalDisplayOrders: (
|
||||
projectPath: string,
|
||||
orders: Array<{ terminalId: string; displayOrder: number }>
|
||||
) => Promise<IPCResult>;
|
||||
|
||||
// Terminal worktree operations (isolated development)
|
||||
createTerminalWorktree: (request: CreateTerminalWorktreeRequest) => Promise<TerminalWorktreeResult>;
|
||||
|
||||
@@ -37,6 +37,8 @@ export interface TerminalSession {
|
||||
outputBuffer: string;
|
||||
createdAt: string;
|
||||
lastActiveAt: string;
|
||||
/** Display order for tab persistence (lower = further left) */
|
||||
displayOrder?: number;
|
||||
/** Associated worktree configuration (validated on restore) */
|
||||
worktreeConfig?: TerminalWorktreeConfig;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user