Put shared code in new files

This commit is contained in:
Alexander Farber
2025-12-13 09:20:58 +01:00
parent 0d40cf2213
commit f5c868702b
5 changed files with 512 additions and 478 deletions

View File

@@ -4,18 +4,12 @@
* SPDX-License-Identifier: Apache-2.0
*/
import { useState, useEffect, useCallback, useRef } from 'react';
import { Box, Text, useInput } from 'ink';
import {
SessionService,
type SessionListItem,
type ListSessionsResult,
getGitBranch,
} from '@qwen-code/qwen-code-core';
import { useState, useEffect, useRef } from 'react';
import { Box, Text } from 'ink';
import { SessionService, getGitBranch } from '@qwen-code/qwen-code-core';
import { theme } from '../semantic-colors.js';
import { formatRelativeTime } from '../utils/formatters.js';
const PAGE_SIZE = 20;
import { useSessionPicker } from '../hooks/useSessionPicker.js';
import { SessionListItemView } from './SessionListItem.js';
export interface ResumeSessionDialogProps {
cwd: string;
@@ -24,186 +18,39 @@ export interface ResumeSessionDialogProps {
availableTerminalHeight?: number;
}
/**
* Truncates text to fit within a given width, adding ellipsis if needed.
*/
function truncateText(text: string, maxWidth: number): string {
if (text.length <= maxWidth) return text;
if (maxWidth <= 3) return text.slice(0, maxWidth);
return text.slice(0, maxWidth - 3) + '...';
}
export function ResumeSessionDialog({
cwd,
onSelect,
onCancel,
availableTerminalHeight,
}: ResumeSessionDialogProps): React.JSX.Element {
const [selectedIndex, setSelectedIndex] = useState(0);
const [sessionState, setSessionState] = useState<{
sessions: SessionListItem[];
hasMore: boolean;
nextCursor?: number;
}>({
sessions: [],
hasMore: false,
nextCursor: undefined,
});
const [filterByBranch, setFilterByBranch] = useState(false);
const [currentBranch, setCurrentBranch] = useState<string | undefined>();
const [isLoading, setIsLoading] = useState(true);
const [scrollOffset, setScrollOffset] = useState(0);
const sessionServiceRef = useRef<SessionService | null>(null);
const isLoadingMoreRef = useRef(false);
const [currentBranch, setCurrentBranch] = useState<string | undefined>();
const [isReady, setIsReady] = useState(false);
// Initialize session service
useEffect(() => {
sessionServiceRef.current = new SessionService(cwd);
setCurrentBranch(getGitBranch(cwd));
setIsReady(true);
}, [cwd]);
// Calculate visible items based on terminal height
const maxVisibleItems = availableTerminalHeight
? Math.max(3, Math.floor((availableTerminalHeight - 6) / 3))
: 5;
// Initialize session service and load sessions
useEffect(() => {
const sessionService = new SessionService(cwd);
sessionServiceRef.current = sessionService;
const branch = getGitBranch(cwd);
setCurrentBranch(branch);
const loadInitialSessions = async () => {
try {
const result: ListSessionsResult = await sessionService.listSessions({
size: PAGE_SIZE,
});
setSessionState({
sessions: result.items,
hasMore: result.hasMore,
nextCursor: result.nextCursor,
});
} finally {
setIsLoading(false);
}
};
loadInitialSessions();
}, [cwd]);
// Filter sessions: exclude empty sessions (0 messages) and optionally by branch
const filteredSessions = sessionState.sessions.filter((session) => {
// Always exclude sessions with no messages
if (session.messageCount === 0) {
return false;
}
// Apply branch filter if enabled
if (filterByBranch && currentBranch) {
return session.gitBranch === currentBranch;
}
return true;
const picker = useSessionPicker({
sessionService: sessionServiceRef.current!,
currentBranch,
onSelect,
onCancel,
maxVisibleItems,
centerSelection: false,
isActive: isReady,
});
// Load more sessions when scrolling near the end
const loadMoreSessions = useCallback(async () => {
if (
!sessionState.hasMore ||
isLoadingMoreRef.current ||
!sessionServiceRef.current
) {
return;
}
isLoadingMoreRef.current = true;
try {
const result: ListSessionsResult =
await sessionServiceRef.current.listSessions({
size: PAGE_SIZE,
cursor: sessionState.nextCursor,
});
setSessionState((prev) => ({
sessions: [...prev.sessions, ...result.items],
hasMore: result.hasMore,
nextCursor: result.nextCursor,
}));
} finally {
isLoadingMoreRef.current = false;
}
}, [sessionState.hasMore, sessionState.nextCursor]);
// Handle keyboard input
useInput((input, key) => {
// Escape to cancel
if (key.escape) {
onCancel();
return;
}
// Enter to select
if (key.return) {
const session = filteredSessions[selectedIndex];
if (session) {
onSelect(session.sessionId);
}
return;
}
// Navigation
if (key.upArrow || input === 'k') {
setSelectedIndex((prev) => {
const newIndex = Math.max(0, prev - 1);
// Adjust scroll offset if needed
if (newIndex < scrollOffset) {
setScrollOffset(newIndex);
}
return newIndex;
});
return;
}
if (key.downArrow || input === 'j') {
if (filteredSessions.length === 0) {
return;
}
setSelectedIndex((prev) => {
const newIndex = Math.min(filteredSessions.length - 1, prev + 1);
// Adjust scroll offset if needed
if (newIndex >= scrollOffset + maxVisibleItems) {
setScrollOffset(newIndex - maxVisibleItems + 1);
}
// Load more if near the end
if (newIndex >= filteredSessions.length - 3 && sessionState.hasMore) {
loadMoreSessions();
}
return newIndex;
});
return;
}
// Toggle branch filter
if (input === 'b' || input === 'B') {
if (currentBranch) {
setFilterByBranch((prev) => !prev);
setSelectedIndex(0);
setScrollOffset(0);
}
return;
}
});
// Reset selection when filter changes
useEffect(() => {
setSelectedIndex(0);
setScrollOffset(0);
}, [filterByBranch]);
// Get visible sessions for rendering
const visibleSessions = filteredSessions.slice(
scrollOffset,
scrollOffset + maxVisibleItems,
);
const showScrollUp = scrollOffset > 0;
const showScrollDown =
scrollOffset + maxVisibleItems < filteredSessions.length;
if (isLoading) {
if (!isReady || picker.isLoading) {
return (
<Box
flexDirection="column"
@@ -233,87 +80,35 @@ export function ResumeSessionDialog({
<Text color={theme.text.primary} bold>
Resume Session
</Text>
{filterByBranch && currentBranch && (
{picker.filterByBranch && currentBranch && (
<Text color={theme.text.secondary}> (branch: {currentBranch})</Text>
)}
</Box>
{/* Session List */}
<Box flexDirection="column" paddingX={1}>
{filteredSessions.length === 0 ? (
{picker.filteredSessions.length === 0 ? (
<Box paddingY={1}>
<Text color={theme.text.secondary}>
{filterByBranch
{picker.filterByBranch
? `No sessions found for branch "${currentBranch}"`
: 'No sessions found'}
</Text>
</Box>
) : (
visibleSessions.map((session, visibleIndex) => {
const actualIndex = scrollOffset + visibleIndex;
const isSelected = actualIndex === selectedIndex;
const isFirst = visibleIndex === 0;
const isLast = visibleIndex === visibleSessions.length - 1;
const timeAgo = formatRelativeTime(session.mtime);
const messageText =
session.messageCount === 1
? '1 message'
: `${session.messageCount} messages`;
// Show scroll indicator on first/last visible items
const showUpIndicator = isFirst && showScrollUp;
const showDownIndicator = isLast && showScrollDown;
// Determine the prefix
const prefix = isSelected
? '> '
: showUpIndicator
? '^ '
: showDownIndicator
? 'v '
: ' ';
const promptText = session.prompt || '(empty prompt)';
const truncatedPrompt = truncateText(
promptText,
(process.stdout.columns || 80) - 10,
);
picker.visibleSessions.map((session, visibleIndex) => {
const actualIndex = picker.scrollOffset + visibleIndex;
return (
<Box
<SessionListItemView
key={session.sessionId}
flexDirection="column"
marginBottom={isLast ? 0 : 1}
>
{/* First line: prefix + prompt text */}
<Box>
<Text
color={
isSelected
? theme.text.accent
: showUpIndicator || showDownIndicator
? theme.text.secondary
: undefined
}
bold={isSelected}
>
{prefix}
</Text>
<Text
color={isSelected ? theme.text.accent : theme.text.primary}
bold={isSelected}
>
{truncatedPrompt}
</Text>
</Box>
{/* Second line: metadata */}
<Box paddingLeft={2}>
<Text color={theme.text.secondary}>
{timeAgo} · {messageText}
{session.gitBranch && ` · ${session.gitBranch}`}
</Text>
</Box>
</Box>
session={session}
isSelected={actualIndex === picker.selectedIndex}
isFirst={visibleIndex === 0}
isLast={visibleIndex === picker.visibleSessions.length - 1}
showScrollUp={picker.showScrollUp}
showScrollDown={picker.showScrollDown}
maxPromptWidth={(process.stdout.columns || 80) - 10}
/>
);
})
)}