refactor: extract workspace terminal sessions, improve sidebar drag-and-drop with nestable lists, and consolidate tab presentation logic
This commit is contained in:
@@ -1,11 +1,13 @@
|
|||||||
import { test, expect, type Page } from "./fixtures";
|
import { test, expect, type Page } from "./fixtures";
|
||||||
import {
|
import {
|
||||||
createAgent,
|
createAgent,
|
||||||
|
createAgentInRepo,
|
||||||
ensureHostSelected,
|
ensureHostSelected,
|
||||||
gotoHome,
|
gotoHome,
|
||||||
setWorkingDirectory,
|
setWorkingDirectory,
|
||||||
} from "./helpers/app";
|
} from "./helpers/app";
|
||||||
import { createTempGitRepo } from "./helpers/workspace";
|
import { createTempGitRepo } from "./helpers/workspace";
|
||||||
|
import { switchWorkspaceViaSidebar } from "./helpers/workspace-ui";
|
||||||
|
|
||||||
function visibleTestId(page: Page, testId: string) {
|
function visibleTestId(page: Page, testId: string) {
|
||||||
return page.locator(`[data-testid="${testId}"]:visible`).first();
|
return page.locator(`[data-testid="${testId}"]:visible`).first();
|
||||||
@@ -171,6 +173,19 @@ async function runTerminalCommand(page: Page, command: string, expectedText: str
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function runTerminalCommandAndWaitForBuffer(
|
||||||
|
page: Page,
|
||||||
|
command: string,
|
||||||
|
expectedText: string
|
||||||
|
): Promise<void> {
|
||||||
|
const surface = visibleTestId(page, "terminal-surface");
|
||||||
|
await expect(surface).toBeVisible({ timeout: 30000 });
|
||||||
|
await surface.click({ force: true });
|
||||||
|
await page.keyboard.type(command, { delay: 1 });
|
||||||
|
await page.keyboard.press("Enter");
|
||||||
|
await expectCurrentTerminalBufferToContain(page, expectedText);
|
||||||
|
}
|
||||||
|
|
||||||
async function runTerminalCommandWithPreEnterEcho(
|
async function runTerminalCommandWithPreEnterEcho(
|
||||||
page: Page,
|
page: Page,
|
||||||
command: string,
|
command: string,
|
||||||
@@ -209,17 +224,21 @@ async function readCurrentTerminalBuffer(page: Page): Promise<string> {
|
|||||||
};
|
};
|
||||||
}).__paseoTerminal;
|
}).__paseoTerminal;
|
||||||
|
|
||||||
const lineCount = terminal?.buffer?.active?.length ?? 0;
|
const buffer = terminal?.buffer?.active;
|
||||||
const getLine = terminal?.buffer?.active?.getLine;
|
const lineCount = buffer?.length ?? 0;
|
||||||
if (!getLine || lineCount <= 0) {
|
if (!buffer || typeof buffer.getLine !== "function" || lineCount <= 0) {
|
||||||
return "";
|
return "";
|
||||||
}
|
}
|
||||||
|
|
||||||
const lines: string[] = [];
|
const lines: string[] = [];
|
||||||
for (let index = 0; index < lineCount; index += 1) {
|
for (let index = 0; index < lineCount; index += 1) {
|
||||||
let line: { translateToString: (trimRight?: boolean) => string } | null = null;
|
let line:
|
||||||
|
| {
|
||||||
|
translateToString: (trimRight?: boolean) => string;
|
||||||
|
}
|
||||||
|
| null = null;
|
||||||
try {
|
try {
|
||||||
line = getLine(index);
|
line = buffer.getLine(index);
|
||||||
} catch {
|
} catch {
|
||||||
return "";
|
return "";
|
||||||
}
|
}
|
||||||
@@ -342,6 +361,48 @@ test("Terminals tab creates multiple terminals and streams command output", asyn
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("new terminal does not inherit output from the previously selected terminal", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
const repo = await createTempGitRepo("paseo-e2e-terminal-output-isolation-");
|
||||||
|
|
||||||
|
try {
|
||||||
|
const serverId = process.env.E2E_SERVER_ID;
|
||||||
|
if (!serverId) {
|
||||||
|
throw new Error("E2E_SERVER_ID is not set.");
|
||||||
|
}
|
||||||
|
|
||||||
|
await createAgentInRepo(page, {
|
||||||
|
directory: repo.path,
|
||||||
|
prompt: "hello",
|
||||||
|
});
|
||||||
|
await switchWorkspaceViaSidebar({
|
||||||
|
page,
|
||||||
|
serverId,
|
||||||
|
targetWorkspacePath: repo.path,
|
||||||
|
});
|
||||||
|
await expect(page.getByTestId("workspace-new-terminal-tab").first()).toBeVisible({
|
||||||
|
timeout: 30000,
|
||||||
|
});
|
||||||
|
|
||||||
|
await page.getByTestId("workspace-new-terminal-tab").first().click();
|
||||||
|
await waitForTerminalAttachToSettle(page);
|
||||||
|
const firstMarker = `terminal-isolation-one-${Date.now()}`;
|
||||||
|
await runTerminalCommandAndWaitForBuffer(page, `echo ${firstMarker}`, firstMarker);
|
||||||
|
|
||||||
|
await page.getByTestId("workspace-new-terminal-tab").first().click();
|
||||||
|
await waitForTerminalAttachToSettle(page);
|
||||||
|
|
||||||
|
await expectCurrentTerminalBufferNotToContain(page, firstMarker);
|
||||||
|
|
||||||
|
const secondMarker = `terminal-isolation-two-${Date.now()}`;
|
||||||
|
await runTerminalCommandAndWaitForBuffer(page, `echo ${secondMarker}`, secondMarker);
|
||||||
|
await expectCurrentTerminalBufferNotToContain(page, firstMarker);
|
||||||
|
} finally {
|
||||||
|
await repo.cleanup();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
test("terminal reattaches cleanly after heavy output and tab switches", async ({ page }) => {
|
test("terminal reattaches cleanly after heavy output and tab switches", async ({ page }) => {
|
||||||
const repo = await createTempGitRepo("paseo-e2e-terminal-reattach-");
|
const repo = await createTempGitRepo("paseo-e2e-terminal-reattach-");
|
||||||
|
|
||||||
|
|||||||
@@ -32,8 +32,7 @@ import {
|
|||||||
HorizontalScrollProvider,
|
HorizontalScrollProvider,
|
||||||
useHorizontalScrollOptional,
|
useHorizontalScrollOptional,
|
||||||
} from "@/contexts/horizontal-scroll-context";
|
} from "@/contexts/horizontal-scroll-context";
|
||||||
import { getIsTauri, getIsTauriMac } from "@/constants/layout";
|
import { getIsTauri } from "@/constants/layout";
|
||||||
import { useTrafficLightPadding } from "@/utils/tauri-window";
|
|
||||||
import { CommandCenter } from "@/components/command-center";
|
import { CommandCenter } from "@/components/command-center";
|
||||||
import { KeyboardShortcutsDialog } from "@/components/keyboard-shortcuts-dialog";
|
import { KeyboardShortcutsDialog } from "@/components/keyboard-shortcuts-dialog";
|
||||||
import { useKeyboardShortcuts } from "@/hooks/use-keyboard-shortcuts";
|
import { useKeyboardShortcuts } from "@/hooks/use-keyboard-shortcuts";
|
||||||
@@ -276,15 +275,11 @@ function AppContainer({ children, selectedAgentId }: AppContainerProps) {
|
|||||||
]
|
]
|
||||||
);
|
);
|
||||||
|
|
||||||
// When sidebar is collapsed on desktop Tauri macOS, add left padding for traffic lights
|
|
||||||
const trafficLightPadding = useTrafficLightPadding();
|
|
||||||
const needsTrafficLightPadding = !isMobile && !isOpen && getIsTauriMac();
|
|
||||||
|
|
||||||
const content = (
|
const content = (
|
||||||
<View style={{ flex: 1, backgroundColor: theme.colors.surface0 }}>
|
<View style={{ flex: 1, backgroundColor: theme.colors.surface0 }}>
|
||||||
<View style={{ flex: 1, flexDirection: "row" }}>
|
<View style={{ flex: 1, flexDirection: "row" }}>
|
||||||
{!isMobile && chromeEnabled && <LeftSidebar selectedAgentId={selectedAgentId} />}
|
{!isMobile && chromeEnabled && <LeftSidebar selectedAgentId={selectedAgentId} />}
|
||||||
<View style={{ flex: 1, paddingLeft: needsTrafficLightPadding ? trafficLightPadding.left : 0 }}>
|
<View style={{ flex: 1 }}>
|
||||||
{children}
|
{children}
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
import { RefreshControl } from "react-native";
|
import { RefreshControl } from "react-native";
|
||||||
import { useCallback, useState } from "react";
|
import { useCallback, useState } from "react";
|
||||||
import DraggableFlatList, { type RenderItemParams } from "react-native-draggable-flatlist";
|
import DraggableFlatList, {
|
||||||
|
NestableDraggableFlatList,
|
||||||
|
type RenderItemParams,
|
||||||
|
} from "react-native-draggable-flatlist";
|
||||||
import { useUnistyles } from "react-native-unistyles";
|
import { useUnistyles } from "react-native-unistyles";
|
||||||
import type {
|
import type {
|
||||||
DraggableListProps,
|
DraggableListProps,
|
||||||
@@ -30,63 +33,56 @@ export function DraggableList<T>({
|
|||||||
simultaneousGestureRef,
|
simultaneousGestureRef,
|
||||||
waitFor,
|
waitFor,
|
||||||
onDragBegin: onDragBeginProp,
|
onDragBegin: onDragBeginProp,
|
||||||
onDragIntent: onDragIntentProp,
|
nestable = false,
|
||||||
onDragRelease: onDragReleaseProp,
|
|
||||||
nestable: _nestable = false,
|
|
||||||
}: DraggableListProps<T>) {
|
}: DraggableListProps<T>) {
|
||||||
const { theme } = useUnistyles();
|
const { theme } = useUnistyles();
|
||||||
const [isDragging, setIsDragging] = useState(false);
|
const [isDragging, setIsDragging] = useState(false);
|
||||||
|
|
||||||
|
// Pass the ref directly to DraggableFlatList - it handles gesture
|
||||||
|
// coordination internally for nestable lists.
|
||||||
const simultaneousHandlers = simultaneousGestureRef ? [simultaneousGestureRef] : undefined;
|
const simultaneousHandlers = simultaneousGestureRef ? [simultaneousGestureRef] : undefined;
|
||||||
|
|
||||||
const handleRenderItem = useCallback(
|
const handleRenderItem = useCallback(
|
||||||
({ item, drag, isActive, getIndex }: RenderItemParams<T>) => {
|
({ item, drag, isActive, getIndex }: RenderItemParams<T>) => {
|
||||||
const index = getIndex() ?? 0;
|
const index = getIndex() ?? 0;
|
||||||
const itemKey = keyExtractor(item, index);
|
|
||||||
const dragWithLog = () => {
|
|
||||||
console.log("[sidebar-dnd-debug] row drag() called", { testID, itemKey, index });
|
|
||||||
onDragIntentProp?.();
|
|
||||||
drag();
|
|
||||||
};
|
|
||||||
const info: DraggableRenderItemInfo<T> = {
|
const info: DraggableRenderItemInfo<T> = {
|
||||||
item,
|
item,
|
||||||
index,
|
index,
|
||||||
drag: dragWithLog,
|
drag,
|
||||||
isActive,
|
isActive,
|
||||||
};
|
};
|
||||||
return renderItem(info);
|
return renderItem(info);
|
||||||
},
|
},
|
||||||
[keyExtractor, onDragIntentProp, renderItem, testID]
|
[renderItem]
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleDragEnd = useCallback(
|
const handleDragEnd = useCallback(
|
||||||
({ data: newData }: { data: T[] }) => {
|
({ data: newData }: { data: T[] }) => {
|
||||||
console.log("[sidebar-dnd-debug] list onDragEnd", { testID, count: newData.length });
|
|
||||||
setIsDragging(false);
|
setIsDragging(false);
|
||||||
onDragEnd(newData);
|
onDragEnd(newData);
|
||||||
},
|
},
|
||||||
[onDragEnd, testID]
|
[onDragEnd]
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleDragBegin = useCallback(() => {
|
const handleDragBegin = useCallback(() => {
|
||||||
console.log("[sidebar-dnd-debug] list onDragBegin", { testID });
|
|
||||||
setIsDragging(true);
|
setIsDragging(true);
|
||||||
onDragBeginProp?.();
|
onDragBeginProp?.();
|
||||||
}, [onDragBeginProp, testID]);
|
}, [onDragBeginProp]);
|
||||||
|
|
||||||
const handleRelease = useCallback(() => {
|
const handleRelease = useCallback(() => {
|
||||||
console.log("[sidebar-dnd-debug] list onRelease", { testID });
|
|
||||||
setIsDragging(false);
|
setIsDragging(false);
|
||||||
onDragReleaseProp?.();
|
}, []);
|
||||||
}, [onDragReleaseProp, testID]);
|
|
||||||
|
|
||||||
const showRefreshControl = Boolean(onRefresh) && (!isDragging || Boolean(refreshing));
|
const showRefreshControl = Boolean(onRefresh) && (!isDragging || Boolean(refreshing));
|
||||||
const resolvedContainerStyle =
|
const resolvedContainerStyle =
|
||||||
containerStyle ?? (scrollEnabled ? { flex: 1 } : undefined);
|
containerStyle ?? (scrollEnabled ? { flex: 1 } : undefined);
|
||||||
const shouldShowRefreshControl = showRefreshControl;
|
const shouldShowRefreshControl = showRefreshControl && !nestable;
|
||||||
|
const ListComponent: typeof DraggableFlatList = (nestable
|
||||||
|
? (NestableDraggableFlatList as any)
|
||||||
|
: DraggableFlatList) as any;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DraggableFlatList
|
<ListComponent
|
||||||
testID={testID}
|
testID={testID}
|
||||||
data={data}
|
data={data}
|
||||||
keyExtractor={keyExtractor}
|
keyExtractor={keyExtractor}
|
||||||
@@ -101,7 +97,9 @@ export function DraggableList<T>({
|
|||||||
showsVerticalScrollIndicator={showsVerticalScrollIndicator}
|
showsVerticalScrollIndicator={showsVerticalScrollIndicator}
|
||||||
scrollEnabled={scrollEnabled}
|
scrollEnabled={scrollEnabled}
|
||||||
simultaneousHandlers={simultaneousHandlers}
|
simultaneousHandlers={simultaneousHandlers}
|
||||||
activationDistance={6}
|
// Higher activation distance reduces accidental drag capture while nested
|
||||||
|
// lists are inside a scroll container.
|
||||||
|
activationDistance={20}
|
||||||
onDragBegin={handleDragBegin}
|
onDragBegin={handleDragBegin}
|
||||||
onRelease={handleRelease}
|
onRelease={handleRelease}
|
||||||
// @ts-ignore - waitFor is supported by RNGH FlatList but missing from DraggableFlatList types
|
// @ts-ignore - waitFor is supported by RNGH FlatList but missing from DraggableFlatList types
|
||||||
|
|||||||
@@ -1,13 +1,15 @@
|
|||||||
import type { ReactNode } from "react";
|
import type { ReactNode } from "react";
|
||||||
import { View, type StyleProp, type ViewStyle } from "react-native";
|
import { View, type StyleProp, type ViewStyle } from "react-native";
|
||||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
import { StyleSheet, UnistylesRuntime } from "react-native-unistyles";
|
import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles";
|
||||||
import {
|
import {
|
||||||
HEADER_INNER_HEIGHT,
|
HEADER_INNER_HEIGHT,
|
||||||
HEADER_INNER_HEIGHT_MOBILE,
|
HEADER_INNER_HEIGHT_MOBILE,
|
||||||
HEADER_TOP_PADDING_MOBILE,
|
HEADER_TOP_PADDING_MOBILE,
|
||||||
|
getIsTauriMac,
|
||||||
} from "@/constants/layout";
|
} from "@/constants/layout";
|
||||||
import { useTauriDragHandlers } from "@/utils/tauri-window";
|
import { useTauriDragHandlers, useTrafficLightPadding } from "@/utils/tauri-window";
|
||||||
|
import { usePanelStore } from "@/stores/panel-store";
|
||||||
|
|
||||||
interface ScreenHeaderProps {
|
interface ScreenHeaderProps {
|
||||||
left?: ReactNode;
|
left?: ReactNode;
|
||||||
@@ -26,19 +28,29 @@ export function ScreenHeader({
|
|||||||
leftStyle,
|
leftStyle,
|
||||||
rightStyle,
|
rightStyle,
|
||||||
}: ScreenHeaderProps) {
|
}: ScreenHeaderProps) {
|
||||||
|
const { theme } = useUnistyles();
|
||||||
const insets = useSafeAreaInsets();
|
const insets = useSafeAreaInsets();
|
||||||
const isMobile = UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
|
const isMobile = UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
|
||||||
|
const desktopAgentListOpen = usePanelStore((state) => state.desktop.agentListOpen);
|
||||||
|
const trafficLightPadding = useTrafficLightPadding();
|
||||||
// Only add extra padding on mobile for better touch targets; on desktop, only use safe area insets
|
// Only add extra padding on mobile for better touch targets; on desktop, only use safe area insets
|
||||||
const topPadding = isMobile ? HEADER_TOP_PADDING_MOBILE : 0;
|
const topPadding = isMobile ? HEADER_TOP_PADDING_MOBILE : 0;
|
||||||
|
const baseHorizontalPadding = theme.spacing[2];
|
||||||
|
const collapsedSidebarTrafficLightInset =
|
||||||
|
!isMobile && !desktopAgentListOpen && getIsTauriMac()
|
||||||
|
? trafficLightPadding.left
|
||||||
|
: 0;
|
||||||
|
|
||||||
// On Tauri macOS, enable window dragging and double-click to maximize
|
// On Tauri macOS, enable window dragging and double-click to maximize
|
||||||
// Left padding for traffic lights is handled by _layout.tsx when sidebar is collapsed
|
|
||||||
const dragHandlers = useTauriDragHandlers();
|
const dragHandlers = useTauriDragHandlers();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={styles.header}>
|
<View style={styles.header}>
|
||||||
<View style={[styles.inner, { paddingTop: insets.top + topPadding }]}>
|
<View style={[styles.inner, { paddingTop: insets.top + topPadding }]}>
|
||||||
<View style={styles.row} {...dragHandlers}>
|
<View
|
||||||
|
style={[styles.row, { paddingLeft: baseHorizontalPadding + collapsedSidebarTrafficLightInset }]}
|
||||||
|
{...dragHandlers}
|
||||||
|
>
|
||||||
<View style={[styles.left, leftStyle]}>{left}</View>
|
<View style={[styles.left, leftStyle]}>{left}</View>
|
||||||
<View style={[styles.right, rightStyle]}>{right}</View>
|
<View style={[styles.right, rightStyle]}>{right}</View>
|
||||||
</View>
|
</View>
|
||||||
|
|||||||
@@ -32,6 +32,14 @@ import {
|
|||||||
} from '@/utils/host-routes'
|
} from '@/utils/host-routes'
|
||||||
|
|
||||||
const DESKTOP_SIDEBAR_WIDTH = 320
|
const DESKTOP_SIDEBAR_WIDTH = 320
|
||||||
|
const IS_DEV = Boolean((globalThis as { __DEV__?: boolean }).__DEV__)
|
||||||
|
|
||||||
|
function logLeftSidebarCloseGesture(event: string, details: Record<string, unknown>): void {
|
||||||
|
if (!IS_DEV) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
console.log(`[LeftSidebarCloseGesture] ${event}`, details)
|
||||||
|
}
|
||||||
|
|
||||||
interface LeftSidebarProps {
|
interface LeftSidebarProps {
|
||||||
selectedAgentId?: string
|
selectedAgentId?: string
|
||||||
@@ -253,6 +261,7 @@ export function LeftSidebar({ selectedAgentId: _selectedAgentId }: LeftSidebarPr
|
|||||||
})
|
})
|
||||||
.onStart(() => {
|
.onStart(() => {
|
||||||
isGesturing.value = true
|
isGesturing.value = true
|
||||||
|
runOnJS(logLeftSidebarCloseGesture)('start', { isOpen, isMobile })
|
||||||
})
|
})
|
||||||
.onUpdate((event) => {
|
.onUpdate((event) => {
|
||||||
if (!isMobile) return
|
if (!isMobile) return
|
||||||
@@ -270,6 +279,11 @@ export function LeftSidebar({ selectedAgentId: _selectedAgentId }: LeftSidebarPr
|
|||||||
isGesturing.value = false
|
isGesturing.value = false
|
||||||
if (!isMobile) return
|
if (!isMobile) return
|
||||||
const shouldClose = event.translationX < -windowWidth / 3 || event.velocityX < -500
|
const shouldClose = event.translationX < -windowWidth / 3 || event.velocityX < -500
|
||||||
|
runOnJS(logLeftSidebarCloseGesture)('end', {
|
||||||
|
translationX: event.translationX,
|
||||||
|
velocityX: event.velocityX,
|
||||||
|
shouldClose,
|
||||||
|
})
|
||||||
if (shouldClose) {
|
if (shouldClose) {
|
||||||
animateToClose()
|
animateToClose()
|
||||||
runOnJS(handleClose)()
|
runOnJS(handleClose)()
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ import { router, usePathname } from 'expo-router'
|
|||||||
import { StyleSheet, UnistylesRuntime, useUnistyles } from 'react-native-unistyles'
|
import { StyleSheet, UnistylesRuntime, useUnistyles } from 'react-native-unistyles'
|
||||||
import { type GestureType } from 'react-native-gesture-handler'
|
import { type GestureType } from 'react-native-gesture-handler'
|
||||||
import { ChevronDown, ChevronRight } from 'lucide-react-native'
|
import { ChevronDown, ChevronRight } from 'lucide-react-native'
|
||||||
|
import { NestableScrollContainer } from 'react-native-draggable-flatlist'
|
||||||
import { DraggableList, type DraggableRenderItemInfo } from './draggable-list'
|
import { DraggableList, type DraggableRenderItemInfo } from './draggable-list'
|
||||||
import type { DraggableListDragHandleProps } from './draggable-list.types'
|
import type { DraggableListDragHandleProps } from './draggable-list.types'
|
||||||
import { getHostRuntimeStore, isHostRuntimeConnected } from '@/runtime/host-runtime'
|
import { getHostRuntimeStore, isHostRuntimeConnected } from '@/runtime/host-runtime'
|
||||||
@@ -52,6 +53,7 @@ import { useToast } from '@/contexts/toast-context'
|
|||||||
import { useCheckoutGitActionsStore } from '@/stores/checkout-git-actions-store'
|
import { useCheckoutGitActionsStore } from '@/stores/checkout-git-actions-store'
|
||||||
import { buildSidebarShortcutModel } from '@/utils/sidebar-shortcuts'
|
import { buildSidebarShortcutModel } from '@/utils/sidebar-shortcuts'
|
||||||
import { hasVisibleOrderChanged, mergeWithRemainder } from '@/utils/sidebar-reorder'
|
import { hasVisibleOrderChanged, mergeWithRemainder } from '@/utils/sidebar-reorder'
|
||||||
|
import { decideLongPressMove } from '@/utils/sidebar-gesture-arbitration'
|
||||||
import { confirmDialog } from '@/utils/confirm-dialog'
|
import { confirmDialog } from '@/utils/confirm-dialog'
|
||||||
|
|
||||||
const PASEO_WORKTREE_PATH_MARKER = '/.paseo/worktrees'
|
const PASEO_WORKTREE_PATH_MARKER = '/.paseo/worktrees'
|
||||||
@@ -160,6 +162,7 @@ function useLongPressDragInteraction(input: {
|
|||||||
}) {
|
}) {
|
||||||
const didLongPressRef = useRef(false)
|
const didLongPressRef = useRef(false)
|
||||||
const dragArmedRef = useRef(false)
|
const dragArmedRef = useRef(false)
|
||||||
|
const dragActivatedRef = useRef(false)
|
||||||
const didStartDragRef = useRef(false)
|
const didStartDragRef = useRef(false)
|
||||||
const scrollIntentRef = useRef(false)
|
const scrollIntentRef = useRef(false)
|
||||||
const menuOpenedRef = useRef(false)
|
const menuOpenedRef = useRef(false)
|
||||||
@@ -212,7 +215,8 @@ function useLongPressDragInteraction(input: {
|
|||||||
const armTimers = useCallback(() => {
|
const armTimers = useCallback(() => {
|
||||||
clearTimers()
|
clearTimers()
|
||||||
|
|
||||||
const DRAG_ARM_DELAY_MS = 140
|
const DRAG_ARM_DELAY_MS = 180
|
||||||
|
const DRAG_ARM_STATIONARY_SLOP_PX = 4
|
||||||
const CONTEXT_MENU_DELAY_MS = 450
|
const CONTEXT_MENU_DELAY_MS = 450
|
||||||
const CONTEXT_MENU_STATIONARY_SLOP_PX = 6
|
const CONTEXT_MENU_STATIONARY_SLOP_PX = 6
|
||||||
|
|
||||||
@@ -220,9 +224,27 @@ function useLongPressDragInteraction(input: {
|
|||||||
if (scrollIntentRef.current || didStartDragRef.current || menuOpenedRef.current) {
|
if (scrollIntentRef.current || didStartDragRef.current || menuOpenedRef.current) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
const start = touchStartRef.current
|
||||||
|
const current = touchCurrentRef.current ?? start
|
||||||
|
if (!start || !current) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const dx = current.x - start.x
|
||||||
|
const dy = current.y - start.y
|
||||||
|
const distance = Math.sqrt(dx * dx + dy * dy)
|
||||||
|
if (distance > DRAG_ARM_STATIONARY_SLOP_PX) {
|
||||||
|
console.log('[sidebar-dnd-debug] drag arm cancelled (movement)', {
|
||||||
|
id: input.debugId,
|
||||||
|
distance,
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
dragArmedRef.current = true
|
dragArmedRef.current = true
|
||||||
|
dragActivatedRef.current = true
|
||||||
|
didLongPressRef.current = true
|
||||||
console.log('[sidebar-dnd-debug] drag armed', { id: input.debugId })
|
console.log('[sidebar-dnd-debug] drag armed', { id: input.debugId })
|
||||||
void Haptics.selectionAsync().catch(() => {})
|
void Haptics.selectionAsync().catch(() => {})
|
||||||
|
input.drag()
|
||||||
}, DRAG_ARM_DELAY_MS)
|
}, DRAG_ARM_DELAY_MS)
|
||||||
|
|
||||||
if (!input.menuController || Platform.OS === 'web') {
|
if (!input.menuController || Platform.OS === 'web') {
|
||||||
@@ -256,14 +278,16 @@ function useLongPressDragInteraction(input: {
|
|||||||
|
|
||||||
const handleDragIntent = useCallback(
|
const handleDragIntent = useCallback(
|
||||||
(details: { dx: number; dy: number; distance: number }) => {
|
(details: { dx: number; dy: number; distance: number }) => {
|
||||||
|
if (!dragActivatedRef.current) {
|
||||||
|
return
|
||||||
|
}
|
||||||
didStartDragRef.current = true
|
didStartDragRef.current = true
|
||||||
didLongPressRef.current = true
|
didLongPressRef.current = true
|
||||||
clearTimers()
|
clearTimers()
|
||||||
console.log('[sidebar-dnd-debug] drag intent detected', { id: input.debugId, ...details })
|
console.log('[sidebar-dnd-debug] drag movement detected', { id: input.debugId, ...details })
|
||||||
void Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium).catch(() => {})
|
void Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium).catch(() => {})
|
||||||
input.drag()
|
|
||||||
},
|
},
|
||||||
[clearTimers, input]
|
[clearTimers, input.debugId]
|
||||||
)
|
)
|
||||||
|
|
||||||
const handleScrollIntent = useCallback(
|
const handleScrollIntent = useCallback(
|
||||||
@@ -276,9 +300,19 @@ function useLongPressDragInteraction(input: {
|
|||||||
[clearTimers, input.debugId]
|
[clearTimers, input.debugId]
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const handleSwipeIntent = useCallback(
|
||||||
|
(details: { dx: number; dy: number; distance: number }) => {
|
||||||
|
didLongPressRef.current = true
|
||||||
|
clearTimers()
|
||||||
|
console.log('[sidebar-dnd-debug] swipe intent detected', { id: input.debugId, ...details })
|
||||||
|
},
|
||||||
|
[clearTimers, input.debugId]
|
||||||
|
)
|
||||||
|
|
||||||
const handlePressIn = useCallback((event: GestureResponderEvent) => {
|
const handlePressIn = useCallback((event: GestureResponderEvent) => {
|
||||||
didLongPressRef.current = false
|
didLongPressRef.current = false
|
||||||
dragArmedRef.current = false
|
dragArmedRef.current = false
|
||||||
|
dragActivatedRef.current = false
|
||||||
didStartDragRef.current = false
|
didStartDragRef.current = false
|
||||||
scrollIntentRef.current = false
|
scrollIntentRef.current = false
|
||||||
menuOpenedRef.current = false
|
menuOpenedRef.current = false
|
||||||
@@ -301,7 +335,7 @@ function useLongPressDragInteraction(input: {
|
|||||||
const handleTouchMove = useCallback(
|
const handleTouchMove = useCallback(
|
||||||
(event: any) => {
|
(event: any) => {
|
||||||
const start = touchStartRef.current
|
const start = touchStartRef.current
|
||||||
if (!start || didStartDragRef.current) {
|
if (!start || didStartDragRef.current || menuOpenedRef.current) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -311,30 +345,34 @@ function useLongPressDragInteraction(input: {
|
|||||||
if (typeof x !== 'number' || typeof y !== 'number') {
|
if (typeof x !== 'number' || typeof y !== 'number') {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
touchCurrentRef.current = { x, y }
|
|
||||||
const dx = x - start.x
|
const current = { x, y }
|
||||||
const dy = y - start.y
|
touchCurrentRef.current = current
|
||||||
const absDx = Math.abs(dx)
|
const dx = current.x - start.x
|
||||||
const absDy = Math.abs(dy)
|
const dy = current.y - start.y
|
||||||
const distance = Math.sqrt(dx * dx + dy * dy)
|
const distance = Math.sqrt(dx * dx + dy * dy)
|
||||||
|
const decision = decideLongPressMove({
|
||||||
|
dragArmed: dragArmedRef.current,
|
||||||
|
didStartDrag: didStartDragRef.current,
|
||||||
|
startPoint: start,
|
||||||
|
currentPoint: current,
|
||||||
|
})
|
||||||
|
|
||||||
const SCROLL_INTENT_SLOP_PX = 8
|
if (decision === 'vertical_scroll') {
|
||||||
const DRAG_START_SLOP_PX = 6
|
|
||||||
|
|
||||||
if (!scrollIntentRef.current && absDy > absDx && absDy > SCROLL_INTENT_SLOP_PX) {
|
|
||||||
handleScrollIntent({ dx, dy, distance })
|
handleScrollIntent({ dx, dy, distance })
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (scrollIntentRef.current) {
|
if (decision === 'horizontal_swipe' || decision === 'cancel_long_press') {
|
||||||
|
handleSwipeIntent({ dx, dy, distance })
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (dragArmedRef.current && distance >= DRAG_START_SLOP_PX) {
|
if (decision === 'start_drag') {
|
||||||
handleDragIntent({ dx, dy, distance })
|
handleDragIntent({ dx, dy, distance })
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[handleDragIntent, handleScrollIntent]
|
[handleDragIntent, handleScrollIntent, handleSwipeIntent]
|
||||||
)
|
)
|
||||||
|
|
||||||
const handlePressOut = useCallback(() => {
|
const handlePressOut = useCallback(() => {
|
||||||
@@ -343,11 +381,13 @@ function useLongPressDragInteraction(input: {
|
|||||||
id: input.debugId,
|
id: input.debugId,
|
||||||
didLongPress: didLongPressRef.current,
|
didLongPress: didLongPressRef.current,
|
||||||
didStartDrag: didStartDragRef.current,
|
didStartDrag: didStartDragRef.current,
|
||||||
|
dragActivated: dragActivatedRef.current,
|
||||||
scrollIntent: scrollIntentRef.current,
|
scrollIntent: scrollIntentRef.current,
|
||||||
dragArmed: dragArmedRef.current,
|
dragArmed: dragArmedRef.current,
|
||||||
menuOpened: menuOpenedRef.current,
|
menuOpened: menuOpenedRef.current,
|
||||||
})
|
})
|
||||||
dragArmedRef.current = false
|
dragArmedRef.current = false
|
||||||
|
dragActivatedRef.current = false
|
||||||
touchStartRef.current = null
|
touchStartRef.current = null
|
||||||
touchCurrentRef.current = null
|
touchCurrentRef.current = null
|
||||||
}, [clearTimers, input.debugId])
|
}, [clearTimers, input.debugId])
|
||||||
@@ -371,10 +411,9 @@ function ProjectHeaderRow({
|
|||||||
isDragging,
|
isDragging,
|
||||||
dragHandleProps,
|
dragHandleProps,
|
||||||
}: ProjectHeaderRowProps) {
|
}: ProjectHeaderRowProps) {
|
||||||
const menuController = useContextMenu()
|
|
||||||
const interaction = useLongPressDragInteraction({
|
const interaction = useLongPressDragInteraction({
|
||||||
drag,
|
drag,
|
||||||
menuController,
|
menuController: null,
|
||||||
debugId: `project:${project.projectKey}`,
|
debugId: `project:${project.projectKey}`,
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -387,8 +426,7 @@ function ProjectHeaderRow({
|
|||||||
}, [interaction.didLongPressRef, onToggle])
|
}, [interaction.didLongPressRef, onToggle])
|
||||||
|
|
||||||
const trigger = (
|
const trigger = (
|
||||||
<ContextMenuTrigger
|
<Pressable
|
||||||
enabledOnMobile={false}
|
|
||||||
style={({ pressed, hovered = false }) => [
|
style={({ pressed, hovered = false }) => [
|
||||||
styles.projectRow,
|
styles.projectRow,
|
||||||
isDragging && styles.projectRowDragging,
|
isDragging && styles.projectRowDragging,
|
||||||
@@ -407,12 +445,6 @@ function ProjectHeaderRow({
|
|||||||
ref={dragHandleProps?.setActivatorNodeRef as any}
|
ref={dragHandleProps?.setActivatorNodeRef as any}
|
||||||
style={styles.projectRowLeft}
|
style={styles.projectRowLeft}
|
||||||
>
|
>
|
||||||
{collapsed ? (
|
|
||||||
<ChevronRight size={14} color="#9ca3af" />
|
|
||||||
) : (
|
|
||||||
<ChevronDown size={14} color="#9ca3af" />
|
|
||||||
)}
|
|
||||||
|
|
||||||
{iconDataUri ? (
|
{iconDataUri ? (
|
||||||
<Image source={{ uri: iconDataUri }} style={styles.projectIcon} />
|
<Image source={{ uri: iconDataUri }} style={styles.projectIcon} />
|
||||||
) : (
|
) : (
|
||||||
@@ -424,29 +456,19 @@ function ProjectHeaderRow({
|
|||||||
<Text style={styles.projectTitle} numberOfLines={1}>
|
<Text style={styles.projectTitle} numberOfLines={1}>
|
||||||
{displayName}
|
{displayName}
|
||||||
</Text>
|
</Text>
|
||||||
|
|
||||||
|
{collapsed ? (
|
||||||
|
<ChevronRight size={14} color="#9ca3af" />
|
||||||
|
) : (
|
||||||
|
<ChevronDown size={14} color="#9ca3af" />
|
||||||
|
)}
|
||||||
</View>
|
</View>
|
||||||
</ContextMenuTrigger>
|
</Pressable>
|
||||||
)
|
)
|
||||||
|
|
||||||
return trigger
|
return trigger
|
||||||
}
|
}
|
||||||
|
|
||||||
function ProjectHeaderRowWithMenu(props: ProjectHeaderRowProps) {
|
|
||||||
return (
|
|
||||||
<ContextMenu>
|
|
||||||
<ProjectHeaderRow {...props} />
|
|
||||||
<ContextMenuContent align="start" width={220} testID={`sidebar-project-context-${props.project.projectKey}`}>
|
|
||||||
<ContextMenuItem
|
|
||||||
testID={`sidebar-project-context-${props.project.projectKey}-toggle`}
|
|
||||||
onSelect={props.onToggle}
|
|
||||||
>
|
|
||||||
{props.collapsed ? 'Expand project' : 'Collapse project'}
|
|
||||||
</ContextMenuItem>
|
|
||||||
</ContextMenuContent>
|
|
||||||
</ContextMenu>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function WorkspaceRowInner({
|
function WorkspaceRowInner({
|
||||||
workspace,
|
workspace,
|
||||||
selected,
|
selected,
|
||||||
@@ -550,7 +572,7 @@ function WorkspaceRowInner({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function WorkspaceRowWithMenu({
|
function WorkspaceRowWithMenuContent({
|
||||||
workspace,
|
workspace,
|
||||||
selected,
|
selected,
|
||||||
shortcutNumber,
|
shortcutNumber,
|
||||||
@@ -644,6 +666,41 @@ function WorkspaceRowWithMenu({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function WorkspaceRowWithMenu({
|
||||||
|
workspace,
|
||||||
|
selected,
|
||||||
|
shortcutNumber,
|
||||||
|
showShortcutBadge,
|
||||||
|
onPress,
|
||||||
|
drag,
|
||||||
|
isDragging,
|
||||||
|
dragHandleProps,
|
||||||
|
}: {
|
||||||
|
workspace: SidebarWorkspaceEntry
|
||||||
|
selected: boolean
|
||||||
|
shortcutNumber: number | null
|
||||||
|
showShortcutBadge: boolean
|
||||||
|
onPress: () => void
|
||||||
|
drag: () => void
|
||||||
|
isDragging: boolean
|
||||||
|
dragHandleProps?: DraggableListDragHandleProps
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<ContextMenu>
|
||||||
|
<WorkspaceRowWithMenuContent
|
||||||
|
workspace={workspace}
|
||||||
|
selected={selected}
|
||||||
|
shortcutNumber={shortcutNumber}
|
||||||
|
showShortcutBadge={showShortcutBadge}
|
||||||
|
onPress={onPress}
|
||||||
|
drag={drag}
|
||||||
|
isDragging={isDragging}
|
||||||
|
dragHandleProps={dragHandleProps}
|
||||||
|
/>
|
||||||
|
</ContextMenu>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
function WorkspaceRowPlain({
|
function WorkspaceRowPlain({
|
||||||
workspace,
|
workspace,
|
||||||
selected,
|
selected,
|
||||||
@@ -714,18 +771,16 @@ function WorkspaceRow({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ContextMenu>
|
<WorkspaceRowWithMenu
|
||||||
<WorkspaceRowWithMenu
|
workspace={workspace}
|
||||||
workspace={workspace}
|
selected={selected}
|
||||||
selected={selected}
|
shortcutNumber={shortcutNumber}
|
||||||
shortcutNumber={shortcutNumber}
|
showShortcutBadge={showShortcutBadge}
|
||||||
showShortcutBadge={showShortcutBadge}
|
onPress={onPress}
|
||||||
onPress={onPress}
|
drag={drag}
|
||||||
drag={drag}
|
isDragging={isDragging}
|
||||||
isDragging={isDragging}
|
dragHandleProps={dragHandleProps}
|
||||||
dragHandleProps={dragHandleProps}
|
/>
|
||||||
/>
|
|
||||||
</ContextMenu>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -742,8 +797,6 @@ function ProjectBlock({
|
|||||||
onToggleCollapsed,
|
onToggleCollapsed,
|
||||||
onWorkspacePress,
|
onWorkspacePress,
|
||||||
onWorkspaceReorder,
|
onWorkspaceReorder,
|
||||||
onAnyDragBegin,
|
|
||||||
onAnyDragEnd,
|
|
||||||
drag,
|
drag,
|
||||||
isDragging,
|
isDragging,
|
||||||
dragHandleProps,
|
dragHandleProps,
|
||||||
@@ -761,8 +814,6 @@ function ProjectBlock({
|
|||||||
onToggleCollapsed: () => void
|
onToggleCollapsed: () => void
|
||||||
onWorkspacePress?: () => void
|
onWorkspacePress?: () => void
|
||||||
onWorkspaceReorder: (projectKey: string, workspaces: SidebarWorkspaceEntry[]) => void
|
onWorkspaceReorder: (projectKey: string, workspaces: SidebarWorkspaceEntry[]) => void
|
||||||
onAnyDragBegin: () => void
|
|
||||||
onAnyDragEnd: () => void
|
|
||||||
drag: () => void
|
drag: () => void
|
||||||
isDragging: boolean
|
isDragging: boolean
|
||||||
dragHandleProps?: DraggableListDragHandleProps
|
dragHandleProps?: DraggableListDragHandleProps
|
||||||
@@ -811,7 +862,7 @@ function ProjectBlock({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={styles.projectBlock}>
|
<View style={styles.projectBlock}>
|
||||||
<ProjectHeaderRowWithMenu
|
<ProjectHeaderRow
|
||||||
project={project}
|
project={project}
|
||||||
displayName={displayName}
|
displayName={displayName}
|
||||||
iconDataUri={iconDataUri}
|
iconDataUri={iconDataUri}
|
||||||
@@ -828,13 +879,7 @@ function ProjectBlock({
|
|||||||
data={project.workspaces}
|
data={project.workspaces}
|
||||||
keyExtractor={(workspace) => workspace.workspaceKey}
|
keyExtractor={(workspace) => workspace.workspaceKey}
|
||||||
renderItem={renderWorkspace}
|
renderItem={renderWorkspace}
|
||||||
onDragIntent={onAnyDragBegin}
|
onDragEnd={(workspaces) => onWorkspaceReorder(project.projectKey, workspaces)}
|
||||||
onDragBegin={onAnyDragBegin}
|
|
||||||
onDragRelease={onAnyDragEnd}
|
|
||||||
onDragEnd={(workspaces) => {
|
|
||||||
onWorkspaceReorder(project.projectKey, workspaces)
|
|
||||||
onAnyDragEnd()
|
|
||||||
}}
|
|
||||||
scrollEnabled={false}
|
scrollEnabled={false}
|
||||||
useDragHandle
|
useDragHandle
|
||||||
nestable={useNestable}
|
nestable={useNestable}
|
||||||
@@ -857,9 +902,9 @@ export function SidebarWorkspaceList({
|
|||||||
parentGestureRef,
|
parentGestureRef,
|
||||||
}: SidebarWorkspaceListProps) {
|
}: SidebarWorkspaceListProps) {
|
||||||
const isMobile = UnistylesRuntime.breakpoint === 'xs' || UnistylesRuntime.breakpoint === 'sm'
|
const isMobile = UnistylesRuntime.breakpoint === 'xs' || UnistylesRuntime.breakpoint === 'sm'
|
||||||
|
const isNative = Platform.OS !== 'web'
|
||||||
const pathname = usePathname()
|
const pathname = usePathname()
|
||||||
const [collapsedProjectKeys, setCollapsedProjectKeys] = useState<Set<string>>(new Set())
|
const [collapsedProjectKeys, setCollapsedProjectKeys] = useState<Set<string>>(new Set())
|
||||||
const [outerScrollEnabled, setOuterScrollEnabled] = useState(true)
|
|
||||||
const isTauri = getIsTauri()
|
const isTauri = getIsTauri()
|
||||||
const altDown = useKeyboardShortcutsStore((state) => state.altDown)
|
const altDown = useKeyboardShortcutsStore((state) => state.altDown)
|
||||||
const cmdOrCtrlDown = useKeyboardShortcutsStore((state) => state.cmdOrCtrlDown)
|
const cmdOrCtrlDown = useKeyboardShortcutsStore((state) => state.cmdOrCtrlDown)
|
||||||
@@ -1008,16 +1053,6 @@ export function SidebarWorkspaceList({
|
|||||||
})
|
})
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const lockOuterScrollForDrag = useCallback((source: string) => {
|
|
||||||
console.log('[sidebar-dnd-debug] outer scroll locked', { source })
|
|
||||||
setOuterScrollEnabled(false)
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
const unlockOuterScrollForDrag = useCallback((source: string) => {
|
|
||||||
console.log('[sidebar-dnd-debug] outer scroll unlocked', { source })
|
|
||||||
setOuterScrollEnabled(true)
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
const handleProjectDragEnd = useCallback(
|
const handleProjectDragEnd = useCallback(
|
||||||
(reorderedProjects: SidebarProjectEntry[]) => {
|
(reorderedProjects: SidebarProjectEntry[]) => {
|
||||||
if (!serverId) {
|
if (!serverId) {
|
||||||
@@ -1091,12 +1126,10 @@ export function SidebarWorkspaceList({
|
|||||||
onToggleCollapsed={() => toggleProjectCollapsed(item.projectKey)}
|
onToggleCollapsed={() => toggleProjectCollapsed(item.projectKey)}
|
||||||
onWorkspacePress={onWorkspacePress}
|
onWorkspacePress={onWorkspacePress}
|
||||||
onWorkspaceReorder={handleWorkspaceReorder}
|
onWorkspaceReorder={handleWorkspaceReorder}
|
||||||
onAnyDragBegin={() => lockOuterScrollForDrag(`workspace:${item.projectKey}`)}
|
|
||||||
onAnyDragEnd={() => unlockOuterScrollForDrag(`workspace:${item.projectKey}`)}
|
|
||||||
drag={drag}
|
drag={drag}
|
||||||
isDragging={isActive}
|
isDragging={isActive}
|
||||||
dragHandleProps={dragHandleProps}
|
dragHandleProps={dragHandleProps}
|
||||||
useNestable={false}
|
useNestable={isNative}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
@@ -1111,8 +1144,7 @@ export function SidebarWorkspaceList({
|
|||||||
shortcutModel.shortcutIndexByWorkspaceKey,
|
shortcutModel.shortcutIndexByWorkspaceKey,
|
||||||
showShortcutBadges,
|
showShortcutBadges,
|
||||||
toggleProjectCollapsed,
|
toggleProjectCollapsed,
|
||||||
lockOuterScrollForDrag,
|
isNative,
|
||||||
unlockOuterScrollForDrag,
|
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -1126,16 +1158,10 @@ export function SidebarWorkspaceList({
|
|||||||
data={projects}
|
data={projects}
|
||||||
keyExtractor={(project) => project.projectKey}
|
keyExtractor={(project) => project.projectKey}
|
||||||
renderItem={renderProject}
|
renderItem={renderProject}
|
||||||
onDragIntent={() => lockOuterScrollForDrag('project-list-intent')}
|
onDragEnd={handleProjectDragEnd}
|
||||||
onDragBegin={() => lockOuterScrollForDrag('project-list-begin')}
|
|
||||||
onDragRelease={() => unlockOuterScrollForDrag('project-list-release')}
|
|
||||||
onDragEnd={(reorderedProjects) => {
|
|
||||||
handleProjectDragEnd(reorderedProjects)
|
|
||||||
unlockOuterScrollForDrag('project-list-end')
|
|
||||||
}}
|
|
||||||
scrollEnabled={false}
|
scrollEnabled={false}
|
||||||
useDragHandle
|
useDragHandle
|
||||||
nestable={false}
|
nestable={isNative}
|
||||||
simultaneousGestureRef={parentGestureRef}
|
simultaneousGestureRef={parentGestureRef}
|
||||||
containerStyle={styles.projectListContainer}
|
containerStyle={styles.projectListContainer}
|
||||||
/>
|
/>
|
||||||
@@ -1146,18 +1172,31 @@ export function SidebarWorkspaceList({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={styles.container}>
|
<View style={styles.container}>
|
||||||
<ScrollView
|
{isNative ? (
|
||||||
style={styles.list}
|
<NestableScrollContainer
|
||||||
contentContainerStyle={styles.listContent}
|
style={styles.list}
|
||||||
showsVerticalScrollIndicator={false}
|
contentContainerStyle={styles.listContent}
|
||||||
scrollEnabled={outerScrollEnabled}
|
showsVerticalScrollIndicator={false}
|
||||||
onScrollBeginDrag={() =>
|
onScrollBeginDrag={() =>
|
||||||
console.log('[sidebar-dnd-debug] outer scroll begin', { outerScrollEnabled })
|
console.log('[sidebar-dnd-debug] outer scroll begin')
|
||||||
}
|
}
|
||||||
testID="sidebar-project-workspace-list-scroll"
|
testID="sidebar-project-workspace-list-scroll"
|
||||||
>
|
>
|
||||||
{content}
|
{content}
|
||||||
</ScrollView>
|
</NestableScrollContainer>
|
||||||
|
) : (
|
||||||
|
<ScrollView
|
||||||
|
style={styles.list}
|
||||||
|
contentContainerStyle={styles.listContent}
|
||||||
|
showsVerticalScrollIndicator={false}
|
||||||
|
onScrollBeginDrag={() =>
|
||||||
|
console.log('[sidebar-dnd-debug] outer scroll begin')
|
||||||
|
}
|
||||||
|
testID="sidebar-project-workspace-list-scroll"
|
||||||
|
>
|
||||||
|
{content}
|
||||||
|
</ScrollView>
|
||||||
|
)}
|
||||||
</View>
|
</View>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,10 +29,8 @@ import {
|
|||||||
import { upsertTerminalListEntry } from "@/utils/terminal-list";
|
import { upsertTerminalListEntry } from "@/utils/terminal-list";
|
||||||
import { confirmDialog } from "@/utils/confirm-dialog";
|
import { confirmDialog } from "@/utils/confirm-dialog";
|
||||||
import {
|
import {
|
||||||
getTerminalOutputSession,
|
getWorkspaceTerminalSession,
|
||||||
releaseTerminalOutputSession,
|
} from "@/terminal/runtime/workspace-terminal-session";
|
||||||
retainTerminalOutputSession,
|
|
||||||
} from "@/terminal/runtime/terminal-output-session";
|
|
||||||
import {
|
import {
|
||||||
TerminalStreamController,
|
TerminalStreamController,
|
||||||
type TerminalStreamControllerStatus,
|
type TerminalStreamControllerStatus,
|
||||||
@@ -162,14 +160,15 @@ export function TerminalPane({
|
|||||||
const terminalsQueryKey = useMemo(() => ["terminals", serverId, cwd] as const, [cwd, serverId]);
|
const terminalsQueryKey = useMemo(() => ["terminals", serverId, cwd] as const, [cwd, serverId]);
|
||||||
const lastReportedSizeRef = useRef<{ rows: number; cols: number } | null>(null);
|
const lastReportedSizeRef = useRef<{ rows: number; cols: number } | null>(null);
|
||||||
const streamControllerRef = useRef<TerminalStreamController | null>(null);
|
const streamControllerRef = useRef<TerminalStreamController | null>(null);
|
||||||
const outputSession = useMemo(
|
const workspaceTerminalSession = useMemo(
|
||||||
() =>
|
() =>
|
||||||
getTerminalOutputSession({
|
getWorkspaceTerminalSession({
|
||||||
scopeKey,
|
scopeKey,
|
||||||
maxOutputChars: MAX_OUTPUT_CHARS,
|
maxOutputChars: MAX_OUTPUT_CHARS,
|
||||||
}),
|
}),
|
||||||
[scopeKey]
|
[scopeKey]
|
||||||
);
|
);
|
||||||
|
const outputSession = workspaceTerminalSession.outputSession;
|
||||||
const subscribeOutputSession = useCallback(
|
const subscribeOutputSession = useCallback(
|
||||||
(listener: () => void) => outputSession.subscribe(listener),
|
(listener: () => void) => outputSession.subscribe(listener),
|
||||||
[outputSession]
|
[outputSession]
|
||||||
@@ -178,17 +177,26 @@ export function TerminalPane({
|
|||||||
() => outputSession.getState(),
|
() => outputSession.getState(),
|
||||||
[outputSession]
|
[outputSession]
|
||||||
);
|
);
|
||||||
useEffect(() => {
|
|
||||||
retainTerminalOutputSession({ scopeKey });
|
|
||||||
return () => {
|
|
||||||
releaseTerminalOutputSession({ scopeKey });
|
|
||||||
};
|
|
||||||
}, [scopeKey]);
|
|
||||||
const outputState = useSyncExternalStore(
|
const outputState = useSyncExternalStore(
|
||||||
subscribeOutputSession,
|
subscribeOutputSession,
|
||||||
getOutputSessionState,
|
getOutputSessionState,
|
||||||
getOutputSessionState
|
getOutputSessionState
|
||||||
);
|
);
|
||||||
|
const selectedOutputState = useMemo(() => {
|
||||||
|
if (outputState.selectedTerminalId === selectedTerminalId) {
|
||||||
|
return outputState;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...outputState,
|
||||||
|
selectedTerminalId,
|
||||||
|
snapshotText: outputSession.readSnapshot({ terminalId: selectedTerminalId }),
|
||||||
|
snapshotSequence: 0,
|
||||||
|
chunkText: "",
|
||||||
|
chunkSequence: 0,
|
||||||
|
chunkReplay: false,
|
||||||
|
};
|
||||||
|
}, [outputSession, outputState, selectedTerminalId]);
|
||||||
const [activeStream, setActiveStream] = useState<{
|
const [activeStream, setActiveStream] = useState<{
|
||||||
terminalId: string;
|
terminalId: string;
|
||||||
streamId: number;
|
streamId: number;
|
||||||
@@ -503,6 +511,7 @@ export function TerminalPane({
|
|||||||
const controller = new TerminalStreamController({
|
const controller = new TerminalStreamController({
|
||||||
client,
|
client,
|
||||||
getPreferredSize: () => lastReportedSizeRef.current,
|
getPreferredSize: () => lastReportedSizeRef.current,
|
||||||
|
resumeOffsets: workspaceTerminalSession.resumeOffsets,
|
||||||
onChunk: ({ terminalId, text, replay }) => {
|
onChunk: ({ terminalId, text, replay }) => {
|
||||||
outputSession.append({ terminalId, text, replay });
|
outputSession.append({ terminalId, text, replay });
|
||||||
},
|
},
|
||||||
@@ -523,7 +532,14 @@ export function TerminalPane({
|
|||||||
streamControllerRef.current = null;
|
streamControllerRef.current = null;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}, [client, handleStreamControllerStatus, isConnected, isScreenFocused, outputSession]);
|
}, [
|
||||||
|
client,
|
||||||
|
handleStreamControllerStatus,
|
||||||
|
isConnected,
|
||||||
|
isScreenFocused,
|
||||||
|
outputSession,
|
||||||
|
workspaceTerminalSession.resumeOffsets,
|
||||||
|
]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
pendingTerminalInputRef.current = [];
|
pendingTerminalInputRef.current = [];
|
||||||
@@ -1022,11 +1038,11 @@ export function TerminalPane({
|
|||||||
contentInsetAdjustmentBehavior: "never",
|
contentInsetAdjustmentBehavior: "never",
|
||||||
}}
|
}}
|
||||||
streamKey={`${scopeKey}:${selectedTerminal.id}`}
|
streamKey={`${scopeKey}:${selectedTerminal.id}`}
|
||||||
initialOutputText={outputState.snapshotText}
|
initialOutputText={selectedOutputState.snapshotText}
|
||||||
initialOutputChunkSequence={outputState.snapshotSequence}
|
initialOutputChunkSequence={selectedOutputState.snapshotSequence}
|
||||||
outputChunkText={outputState.chunkText}
|
outputChunkText={selectedOutputState.chunkText}
|
||||||
outputChunkSequence={outputState.chunkSequence}
|
outputChunkSequence={selectedOutputState.chunkSequence}
|
||||||
outputChunkReplay={outputState.chunkReplay}
|
outputChunkReplay={selectedOutputState.chunkReplay}
|
||||||
testId="terminal-surface"
|
testId="terminal-surface"
|
||||||
xtermTheme={xtermTheme}
|
xtermTheme={xtermTheme}
|
||||||
swipeGesturesEnabled={swipeGesturesEnabled}
|
swipeGesturesEnabled={swipeGesturesEnabled}
|
||||||
|
|||||||
@@ -45,6 +45,12 @@ export interface ComboboxProps {
|
|||||||
options: ComboboxOption[]
|
options: ComboboxOption[]
|
||||||
value: string
|
value: string
|
||||||
onSelect: (id: string) => void
|
onSelect: (id: string) => void
|
||||||
|
renderOption?: (input: {
|
||||||
|
option: ComboboxOption
|
||||||
|
selected: boolean
|
||||||
|
active: boolean
|
||||||
|
onPress: () => void
|
||||||
|
}) => ReactElement
|
||||||
onSearchQueryChange?: (query: string) => void
|
onSearchQueryChange?: (query: string) => void
|
||||||
searchable?: boolean
|
searchable?: boolean
|
||||||
placeholder?: string
|
placeholder?: string
|
||||||
@@ -204,6 +210,7 @@ export function Combobox({
|
|||||||
options,
|
options,
|
||||||
value,
|
value,
|
||||||
onSelect,
|
onSelect,
|
||||||
|
renderOption,
|
||||||
onSearchQueryChange,
|
onSearchQueryChange,
|
||||||
searchable = true,
|
searchable = true,
|
||||||
placeholder = 'Search...',
|
placeholder = 'Search...',
|
||||||
@@ -568,15 +575,26 @@ export function Combobox({
|
|||||||
<>
|
<>
|
||||||
{orderedVisibleOptions.length > 0 ? (
|
{orderedVisibleOptions.length > 0 ? (
|
||||||
orderedVisibleOptions.map((opt, index) => (
|
orderedVisibleOptions.map((opt, index) => (
|
||||||
<ComboboxItem
|
renderOption ? (
|
||||||
key={opt.id}
|
<View key={opt.id}>
|
||||||
label={opt.label}
|
{renderOption({
|
||||||
description={opt.description}
|
option: opt,
|
||||||
kind={opt.kind}
|
selected: opt.id === value,
|
||||||
selected={opt.id === value}
|
active: index === activeIndex,
|
||||||
active={index === activeIndex}
|
onPress: () => handleSelect(opt.id),
|
||||||
onPress={() => handleSelect(opt.id)}
|
})}
|
||||||
/>
|
</View>
|
||||||
|
) : (
|
||||||
|
<ComboboxItem
|
||||||
|
key={opt.id}
|
||||||
|
label={opt.label}
|
||||||
|
description={opt.description}
|
||||||
|
kind={opt.kind}
|
||||||
|
selected={opt.id === value}
|
||||||
|
active={index === activeIndex}
|
||||||
|
onPress={() => handleSelect(opt.id)}
|
||||||
|
/>
|
||||||
|
)
|
||||||
))
|
))
|
||||||
) : (
|
) : (
|
||||||
<ComboboxEmpty>{emptyText}</ComboboxEmpty>
|
<ComboboxEmpty>{emptyText}</ComboboxEmpty>
|
||||||
|
|||||||
@@ -1,10 +1,8 @@
|
|||||||
import { useCallback, useMemo, useState, type Dispatch, type SetStateAction } from "react";
|
import { useCallback, useMemo, useState, type Dispatch, type SetStateAction } from "react";
|
||||||
import { ActivityIndicator, Pressable, ScrollView, Text, View, type LayoutChangeEvent } from "react-native";
|
import { ActivityIndicator, Pressable, ScrollView, Text, View, type LayoutChangeEvent } from "react-native";
|
||||||
import { Bot, FileText, Pencil, Plus, SquareTerminal, Terminal, X } from "lucide-react-native";
|
import { Plus, SquareTerminal, X } from "lucide-react-native";
|
||||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||||
import { SortableInlineList } from "@/components/sortable-inline-list";
|
import { SortableInlineList } from "@/components/sortable-inline-list";
|
||||||
import { ClaudeIcon } from "@/components/icons/claude-icon";
|
|
||||||
import { CodexIcon } from "@/components/icons/codex-icon";
|
|
||||||
import {
|
import {
|
||||||
ContextMenu,
|
ContextMenu,
|
||||||
ContextMenuContent,
|
ContextMenuContent,
|
||||||
@@ -14,9 +12,11 @@ import {
|
|||||||
} from "@/components/ui/context-menu";
|
} from "@/components/ui/context-menu";
|
||||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||||
import { useWorkspaceTabLayout } from "@/screens/workspace/use-workspace-tab-layout";
|
import { useWorkspaceTabLayout } from "@/screens/workspace/use-workspace-tab-layout";
|
||||||
|
import {
|
||||||
|
deriveWorkspaceTabPresentation,
|
||||||
|
WorkspaceTabIcon,
|
||||||
|
} from "@/screens/workspace/workspace-tab-presentation";
|
||||||
import type { WorkspaceTabDescriptor } from "@/screens/workspace/workspace-tabs-types";
|
import type { WorkspaceTabDescriptor } from "@/screens/workspace/workspace-tabs-types";
|
||||||
import { deriveSidebarStateBucket } from "@/utils/sidebar-agent-state";
|
|
||||||
import { getStatusDotColor } from "@/utils/status-dot-color";
|
|
||||||
import { encodeFilePathForPathSegment } from "@/utils/host-routes";
|
import { encodeFilePathForPathSegment } from "@/utils/host-routes";
|
||||||
import type { Agent } from "@/stores/session-store";
|
import type { Agent } from "@/stores/session-store";
|
||||||
|
|
||||||
@@ -158,52 +158,7 @@ export function WorkspaceDesktopTabsRow({
|
|||||||
const showLabel = layoutItem?.showLabel ?? true;
|
const showLabel = layoutItem?.showLabel ?? true;
|
||||||
const labelCharCap = layoutItem?.labelCharCap ?? tab.label.length;
|
const labelCharCap = layoutItem?.labelCharCap ?? tab.label.length;
|
||||||
const renderedLabel = showLabel ? tab.label.slice(0, Math.max(1, labelCharCap)) : "";
|
const renderedLabel = showLabel ? tab.label.slice(0, Math.max(1, labelCharCap)) : "";
|
||||||
const iconColor = isActive ? theme.colors.foreground : theme.colors.foregroundMuted;
|
const presentation = deriveWorkspaceTabPresentation({ tab, agent: tabAgent });
|
||||||
const tabAgentStatusBucket = tabAgent
|
|
||||||
? deriveSidebarStateBucket({
|
|
||||||
status: tabAgent.status,
|
|
||||||
pendingPermissionCount: tabAgent.pendingPermissions.length,
|
|
||||||
requiresAttention: tabAgent.requiresAttention,
|
|
||||||
attentionReason: tabAgent.attentionReason,
|
|
||||||
})
|
|
||||||
: null;
|
|
||||||
const tabAgentStatusColor =
|
|
||||||
tabAgentStatusBucket === null
|
|
||||||
? null
|
|
||||||
: getStatusDotColor({
|
|
||||||
theme,
|
|
||||||
bucket: tabAgentStatusBucket,
|
|
||||||
showDoneAsInactive: false,
|
|
||||||
});
|
|
||||||
const icon =
|
|
||||||
tab.kind === "agent" ? (
|
|
||||||
<View style={styles.tabAgentIconWrapper}>
|
|
||||||
{tab.provider === "claude" ? (
|
|
||||||
<ClaudeIcon size={14} color={iconColor} />
|
|
||||||
) : tab.provider === "codex" ? (
|
|
||||||
<CodexIcon size={14} color={iconColor} />
|
|
||||||
) : (
|
|
||||||
<Bot size={14} color={iconColor} />
|
|
||||||
)}
|
|
||||||
{tabAgentStatusColor ? (
|
|
||||||
<View
|
|
||||||
style={[
|
|
||||||
styles.tabStatusDot,
|
|
||||||
{
|
|
||||||
backgroundColor: tabAgentStatusColor,
|
|
||||||
borderColor: theme.colors.surface0,
|
|
||||||
},
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
) : null}
|
|
||||||
</View>
|
|
||||||
) : tab.kind === "draft" ? (
|
|
||||||
<Pencil size={14} color={iconColor} />
|
|
||||||
) : tab.kind === "file" ? (
|
|
||||||
<FileText size={14} color={iconColor} />
|
|
||||||
) : (
|
|
||||||
<Terminal size={14} color={iconColor} />
|
|
||||||
);
|
|
||||||
|
|
||||||
const contextMenuTestId = `workspace-tab-context-${tab.key}`;
|
const contextMenuTestId = `workspace-tab-context-${tab.key}`;
|
||||||
|
|
||||||
@@ -246,9 +201,11 @@ export function WorkspaceDesktopTabsRow({
|
|||||||
ref={dragHandleProps?.setActivatorNodeRef}
|
ref={dragHandleProps?.setActivatorNodeRef}
|
||||||
style={styles.tabHandle}
|
style={styles.tabHandle}
|
||||||
>
|
>
|
||||||
<View style={styles.tabIcon}>{icon}</View>
|
<View style={styles.tabIcon}>
|
||||||
|
<WorkspaceTabIcon presentation={presentation} active={isActive} />
|
||||||
|
</View>
|
||||||
{showLabel ? (
|
{showLabel ? (
|
||||||
tab.kind === "agent" && tab.titleState === "loading" ? (
|
presentation.titleState === "loading" ? (
|
||||||
<View
|
<View
|
||||||
style={[
|
style={[
|
||||||
styles.tabLabelSkeleton,
|
styles.tabLabelSkeleton,
|
||||||
@@ -453,22 +410,6 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
tabIcon: {
|
tabIcon: {
|
||||||
flexShrink: 0,
|
flexShrink: 0,
|
||||||
},
|
},
|
||||||
tabAgentIconWrapper: {
|
|
||||||
position: "relative",
|
|
||||||
width: 14,
|
|
||||||
height: 14,
|
|
||||||
alignItems: "center",
|
|
||||||
justifyContent: "center",
|
|
||||||
},
|
|
||||||
tabStatusDot: {
|
|
||||||
position: "absolute",
|
|
||||||
right: -2,
|
|
||||||
bottom: -2,
|
|
||||||
width: 7,
|
|
||||||
height: 7,
|
|
||||||
borderRadius: theme.borderRadius.full,
|
|
||||||
borderWidth: 1,
|
|
||||||
},
|
|
||||||
tabActive: {
|
tabActive: {
|
||||||
backgroundColor: theme.colors.surface2,
|
backgroundColor: theme.colors.surface2,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -11,16 +11,12 @@ import {
|
|||||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import * as Clipboard from "expo-clipboard";
|
import * as Clipboard from "expo-clipboard";
|
||||||
import {
|
import {
|
||||||
Bot,
|
|
||||||
ChevronDown,
|
ChevronDown,
|
||||||
FileText,
|
|
||||||
Folder,
|
Folder,
|
||||||
GitBranch,
|
GitBranch,
|
||||||
PanelRight,
|
PanelRight,
|
||||||
Plus,
|
Plus,
|
||||||
Pencil,
|
|
||||||
SquareTerminal,
|
SquareTerminal,
|
||||||
Terminal,
|
|
||||||
} from "lucide-react-native";
|
} from "lucide-react-native";
|
||||||
import { GestureDetector } from "react-native-gesture-handler";
|
import { GestureDetector } from "react-native-gesture-handler";
|
||||||
import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles";
|
import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles";
|
||||||
@@ -28,8 +24,6 @@ import { SidebarMenuToggle } from "@/components/headers/menu-header";
|
|||||||
import { HeaderToggleButton } from "@/components/headers/header-toggle-button";
|
import { HeaderToggleButton } from "@/components/headers/header-toggle-button";
|
||||||
import { ScreenHeader } from "@/components/headers/screen-header";
|
import { ScreenHeader } from "@/components/headers/screen-header";
|
||||||
import { Combobox } from "@/components/ui/combobox";
|
import { Combobox } from "@/components/ui/combobox";
|
||||||
import { ClaudeIcon } from "@/components/icons/claude-icon";
|
|
||||||
import { CodexIcon } from "@/components/icons/codex-icon";
|
|
||||||
import {
|
import {
|
||||||
Tooltip,
|
Tooltip,
|
||||||
TooltipContent,
|
TooltipContent,
|
||||||
@@ -58,6 +52,7 @@ import {
|
|||||||
} from "@/utils/host-routes";
|
} from "@/utils/host-routes";
|
||||||
import { normalizeWorkspaceIdentity } from "@/utils/workspace-identity";
|
import { normalizeWorkspaceIdentity } from "@/utils/workspace-identity";
|
||||||
import { useHostRuntimeSession } from "@/runtime/host-runtime";
|
import { useHostRuntimeSession } from "@/runtime/host-runtime";
|
||||||
|
import { useWorkspaceTerminalSessionRetention } from "@/terminal/hooks/use-workspace-terminal-session-retention";
|
||||||
import {
|
import {
|
||||||
checkoutStatusQueryKey,
|
checkoutStatusQueryKey,
|
||||||
type CheckoutStatusPayload,
|
type CheckoutStatusPayload,
|
||||||
@@ -66,14 +61,17 @@ import { AgentReadyScreen } from "@/screens/agent/agent-ready-screen";
|
|||||||
import type { ListTerminalsResponse } from "@server/shared/messages";
|
import type { ListTerminalsResponse } from "@server/shared/messages";
|
||||||
import { upsertTerminalListEntry } from "@/utils/terminal-list";
|
import { upsertTerminalListEntry } from "@/utils/terminal-list";
|
||||||
import { confirmDialog } from "@/utils/confirm-dialog";
|
import { confirmDialog } from "@/utils/confirm-dialog";
|
||||||
import { deriveSidebarStateBucket } from "@/utils/sidebar-agent-state";
|
|
||||||
import { getStatusDotColor } from "@/utils/status-dot-color";
|
|
||||||
import { useArchiveAgent } from "@/hooks/use-archive-agent";
|
import { useArchiveAgent } from "@/hooks/use-archive-agent";
|
||||||
import { buildProviderCommand } from "@/utils/provider-command-templates";
|
import { buildProviderCommand } from "@/utils/provider-command-templates";
|
||||||
import { generateDraftId } from "@/stores/draft-keys";
|
import { generateDraftId } from "@/stores/draft-keys";
|
||||||
import { normalizeAgentSnapshot } from "@/utils/agent-snapshots";
|
import { normalizeAgentSnapshot } from "@/utils/agent-snapshots";
|
||||||
import { WorkspaceDraftAgentTab } from "@/screens/workspace/workspace-draft-agent-tab";
|
import { WorkspaceDraftAgentTab } from "@/screens/workspace/workspace-draft-agent-tab";
|
||||||
import { WorkspaceDesktopTabsRow } from "@/screens/workspace/workspace-desktop-tabs-row";
|
import { WorkspaceDesktopTabsRow } from "@/screens/workspace/workspace-desktop-tabs-row";
|
||||||
|
import {
|
||||||
|
deriveWorkspaceTabPresentation,
|
||||||
|
WorkspaceTabIcon,
|
||||||
|
WorkspaceTabOptionRow,
|
||||||
|
} from "@/screens/workspace/workspace-tab-presentation";
|
||||||
import type { WorkspaceTabDescriptor } from "@/screens/workspace/workspace-tabs-types";
|
import type { WorkspaceTabDescriptor } from "@/screens/workspace/workspace-tabs-types";
|
||||||
import {
|
import {
|
||||||
resolveWorkspaceHeader,
|
resolveWorkspaceHeader,
|
||||||
@@ -145,6 +143,13 @@ function WorkspaceScreenContent({
|
|||||||
const normalizedServerId = trimNonEmpty(decodeSegment(serverId)) ?? "";
|
const normalizedServerId = trimNonEmpty(decodeSegment(serverId)) ?? "";
|
||||||
const normalizedWorkspaceId =
|
const normalizedWorkspaceId =
|
||||||
normalizeWorkspaceIdentity(decodeWorkspaceIdFromPathSegment(workspaceId)) ?? "";
|
normalizeWorkspaceIdentity(decodeWorkspaceIdFromPathSegment(workspaceId)) ?? "";
|
||||||
|
const workspaceTerminalScopeKey =
|
||||||
|
normalizedServerId && normalizedWorkspaceId
|
||||||
|
? `${normalizedServerId}:${normalizedWorkspaceId}`
|
||||||
|
: null;
|
||||||
|
useWorkspaceTerminalSessionRetention({
|
||||||
|
scopeKey: workspaceTerminalScopeKey,
|
||||||
|
});
|
||||||
|
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const { client, isConnected } = useHostRuntimeSession(normalizedServerId);
|
const { client, isConnected } = useHostRuntimeSession(normalizedServerId);
|
||||||
@@ -702,24 +707,32 @@ function WorkspaceScreenContent({
|
|||||||
}, [tabs]);
|
}, [tabs]);
|
||||||
|
|
||||||
const activeTabKey = activeTabId ?? "";
|
const activeTabKey = activeTabId ?? "";
|
||||||
|
const tabPresentationsByKey = useMemo(() => {
|
||||||
|
const map = new Map<string, ReturnType<typeof deriveWorkspaceTabPresentation>>();
|
||||||
|
for (const tab of tabs) {
|
||||||
|
const agent = tab.kind === "agent" ? agentsById.get(tab.agentId) ?? null : null;
|
||||||
|
map.set(tab.key, deriveWorkspaceTabPresentation({ tab, agent }));
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
}, [agentsById, tabs]);
|
||||||
|
const activeTabPresentation = tabPresentationsByKey.get(activeTabKey) ?? null;
|
||||||
|
|
||||||
const tabSwitcherOptions = useMemo(
|
const tabSwitcherOptions = useMemo(
|
||||||
() =>
|
() =>
|
||||||
tabs.map((tab) => ({
|
tabs.map((tab) => ({
|
||||||
id: tab.key,
|
id: tab.key,
|
||||||
label: tab.kind === "agent" && tab.titleState === "loading" ? "Loading..." : tab.label,
|
label: tabPresentationsByKey.get(tab.key)?.titleState === "loading" ? "Loading..." : tab.label,
|
||||||
description: tab.subtitle,
|
description: tab.subtitle,
|
||||||
})),
|
})),
|
||||||
[tabs]
|
[tabPresentationsByKey, tabs]
|
||||||
);
|
);
|
||||||
|
|
||||||
const activeTabLabel = useMemo(() => {
|
const activeTabLabel = useMemo(() => {
|
||||||
const active = tabs.find((tab) => tab.key === activeTabKey);
|
if (activeTabPresentation?.titleState === "loading") {
|
||||||
if (active?.kind === "agent" && active.titleState === "loading") {
|
|
||||||
return "Loading...";
|
return "Loading...";
|
||||||
}
|
}
|
||||||
return active?.label ?? "Select tab";
|
return activeTabPresentation?.label ?? "Select tab";
|
||||||
}, [activeTabKey, tabs]);
|
}, [activeTabPresentation]);
|
||||||
|
|
||||||
const handleCreateDraftTab = useCallback(() => {
|
const handleCreateDraftTab = useCallback(() => {
|
||||||
if (!normalizedServerId || !normalizedWorkspaceId) {
|
if (!normalizedServerId || !normalizedWorkspaceId) {
|
||||||
@@ -1322,69 +1335,9 @@ function WorkspaceScreenContent({
|
|||||||
>
|
>
|
||||||
<View style={styles.switcherTriggerLeft}>
|
<View style={styles.switcherTriggerLeft}>
|
||||||
<View style={styles.switcherTriggerIcon} testID="workspace-active-tab-icon">
|
<View style={styles.switcherTriggerIcon} testID="workspace-active-tab-icon">
|
||||||
{(() => {
|
{activeTabPresentation ? (
|
||||||
const activeDescriptor = tabs.find((tab) => tab.key === activeTabKey) ?? null;
|
<WorkspaceTabIcon presentation={activeTabPresentation} active />
|
||||||
if (!activeDescriptor) {
|
) : null}
|
||||||
return <View style={styles.tabIcon}><Bot size={14} color={theme.colors.foregroundMuted} /></View>;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (activeDescriptor.kind === "terminal") {
|
|
||||||
return <Terminal size={14} color={theme.colors.foreground} />;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (activeDescriptor.kind === "file") {
|
|
||||||
return <FileText size={14} color={theme.colors.foreground} />;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (activeDescriptor.kind === "draft") {
|
|
||||||
return <Pencil size={14} color={theme.colors.foreground} />;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (activeDescriptor.kind !== "agent") {
|
|
||||||
return <Bot size={14} color={theme.colors.foreground} />;
|
|
||||||
}
|
|
||||||
|
|
||||||
const tabAgent = agentsById.get(activeDescriptor.agentId) ?? null;
|
|
||||||
const tabAgentStatusBucket = tabAgent
|
|
||||||
? deriveSidebarStateBucket({
|
|
||||||
status: tabAgent.status,
|
|
||||||
pendingPermissionCount: tabAgent.pendingPermissions.length,
|
|
||||||
requiresAttention: tabAgent.requiresAttention,
|
|
||||||
attentionReason: tabAgent.attentionReason,
|
|
||||||
})
|
|
||||||
: null;
|
|
||||||
const tabAgentStatusColor =
|
|
||||||
tabAgentStatusBucket === null
|
|
||||||
? null
|
|
||||||
: getStatusDotColor({
|
|
||||||
theme,
|
|
||||||
bucket: tabAgentStatusBucket,
|
|
||||||
showDoneAsInactive: false,
|
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
|
||||||
<View style={styles.tabAgentIconWrapper}>
|
|
||||||
{activeDescriptor.provider === "claude" ? (
|
|
||||||
<ClaudeIcon size={14} color={theme.colors.foreground} />
|
|
||||||
) : activeDescriptor.provider === "codex" ? (
|
|
||||||
<CodexIcon size={14} color={theme.colors.foreground} />
|
|
||||||
) : (
|
|
||||||
<Bot size={14} color={theme.colors.foreground} />
|
|
||||||
)}
|
|
||||||
{tabAgentStatusColor ? (
|
|
||||||
<View
|
|
||||||
style={[
|
|
||||||
styles.tabStatusDot,
|
|
||||||
{
|
|
||||||
backgroundColor: tabAgentStatusColor,
|
|
||||||
borderColor: theme.colors.surface0,
|
|
||||||
},
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
) : null}
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
})()}
|
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
<Text style={styles.switcherTriggerText} numberOfLines={1}>
|
<Text style={styles.switcherTriggerText} numberOfLines={1}>
|
||||||
@@ -1456,6 +1409,23 @@ function WorkspaceScreenContent({
|
|||||||
open={isTabSwitcherOpen}
|
open={isTabSwitcherOpen}
|
||||||
onOpenChange={setIsTabSwitcherOpen}
|
onOpenChange={setIsTabSwitcherOpen}
|
||||||
anchorRef={tabSwitcherAnchorRef}
|
anchorRef={tabSwitcherAnchorRef}
|
||||||
|
renderOption={({ option, selected, active, onPress }) => {
|
||||||
|
const tab = tabByKey.get(option.id);
|
||||||
|
if (!tab) {
|
||||||
|
return <View />;
|
||||||
|
}
|
||||||
|
const presentation =
|
||||||
|
tabPresentationsByKey.get(option.id) ??
|
||||||
|
deriveWorkspaceTabPresentation({ tab });
|
||||||
|
return (
|
||||||
|
<WorkspaceTabOptionRow
|
||||||
|
presentation={presentation}
|
||||||
|
selected={selected}
|
||||||
|
active={active}
|
||||||
|
onPress={onPress}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
) : (
|
) : (
|
||||||
@@ -1710,22 +1680,6 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
tabIcon: {
|
tabIcon: {
|
||||||
flexShrink: 0,
|
flexShrink: 0,
|
||||||
},
|
},
|
||||||
tabAgentIconWrapper: {
|
|
||||||
position: "relative",
|
|
||||||
width: 14,
|
|
||||||
height: 14,
|
|
||||||
alignItems: "center",
|
|
||||||
justifyContent: "center",
|
|
||||||
},
|
|
||||||
tabStatusDot: {
|
|
||||||
position: "absolute",
|
|
||||||
right: -2,
|
|
||||||
bottom: -2,
|
|
||||||
width: 7,
|
|
||||||
height: 7,
|
|
||||||
borderRadius: theme.borderRadius.full,
|
|
||||||
borderWidth: 1,
|
|
||||||
},
|
|
||||||
tabActive: {
|
tabActive: {
|
||||||
backgroundColor: theme.colors.surface2,
|
backgroundColor: theme.colors.surface2,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -0,0 +1,196 @@
|
|||||||
|
import type { ReactElement } from "react";
|
||||||
|
import { Pressable, Text, View } from "react-native";
|
||||||
|
import { Bot, Check, FileText, Pencil, Terminal } from "lucide-react-native";
|
||||||
|
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||||
|
import { ClaudeIcon } from "@/components/icons/claude-icon";
|
||||||
|
import { CodexIcon } from "@/components/icons/codex-icon";
|
||||||
|
import type { Agent } from "@/stores/session-store";
|
||||||
|
import type { WorkspaceTabDescriptor } from "@/screens/workspace/workspace-tabs-types";
|
||||||
|
import {
|
||||||
|
deriveSidebarStateBucket,
|
||||||
|
type SidebarStateBucket,
|
||||||
|
} from "@/utils/sidebar-agent-state";
|
||||||
|
import { getStatusDotColor } from "@/utils/status-dot-color";
|
||||||
|
|
||||||
|
export type WorkspaceTabPresentation = {
|
||||||
|
key: string;
|
||||||
|
kind: WorkspaceTabDescriptor["kind"];
|
||||||
|
label: string;
|
||||||
|
subtitle: string;
|
||||||
|
titleState: "ready" | "loading";
|
||||||
|
provider: Agent["provider"] | null;
|
||||||
|
statusBucket: SidebarStateBucket | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function deriveWorkspaceTabPresentation(input: {
|
||||||
|
tab: WorkspaceTabDescriptor;
|
||||||
|
agent?: Agent | null;
|
||||||
|
}): WorkspaceTabPresentation {
|
||||||
|
const { tab, agent = null } = input;
|
||||||
|
return {
|
||||||
|
key: tab.key,
|
||||||
|
kind: tab.kind,
|
||||||
|
label: tab.label,
|
||||||
|
subtitle: tab.subtitle,
|
||||||
|
titleState: tab.kind === "agent" ? tab.titleState : "ready",
|
||||||
|
provider: tab.kind === "agent" ? tab.provider : null,
|
||||||
|
statusBucket:
|
||||||
|
tab.kind === "agent" && agent
|
||||||
|
? deriveSidebarStateBucket({
|
||||||
|
status: agent.status,
|
||||||
|
pendingPermissionCount: agent.pendingPermissions.length,
|
||||||
|
requiresAttention: agent.requiresAttention,
|
||||||
|
attentionReason: agent.attentionReason,
|
||||||
|
})
|
||||||
|
: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
type WorkspaceTabIconProps = {
|
||||||
|
presentation: WorkspaceTabPresentation;
|
||||||
|
active?: boolean;
|
||||||
|
size?: number;
|
||||||
|
statusDotBorderColor?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function WorkspaceTabIcon({
|
||||||
|
presentation,
|
||||||
|
active = false,
|
||||||
|
size = 14,
|
||||||
|
statusDotBorderColor,
|
||||||
|
}: WorkspaceTabIconProps): ReactElement {
|
||||||
|
const { theme } = useUnistyles();
|
||||||
|
const iconColor = active ? theme.colors.foreground : theme.colors.foregroundMuted;
|
||||||
|
const statusDotColor =
|
||||||
|
presentation.statusBucket === null
|
||||||
|
? null
|
||||||
|
: getStatusDotColor({
|
||||||
|
theme,
|
||||||
|
bucket: presentation.statusBucket,
|
||||||
|
showDoneAsInactive: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (presentation.kind === "agent") {
|
||||||
|
return (
|
||||||
|
<View style={[styles.agentIconWrapper, { width: size, height: size }]}>
|
||||||
|
{presentation.provider === "claude" ? (
|
||||||
|
<ClaudeIcon size={size} color={iconColor} />
|
||||||
|
) : presentation.provider === "codex" ? (
|
||||||
|
<CodexIcon size={size} color={iconColor} />
|
||||||
|
) : (
|
||||||
|
<Bot size={size} color={iconColor} />
|
||||||
|
)}
|
||||||
|
{statusDotColor ? (
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
styles.statusDot,
|
||||||
|
{
|
||||||
|
backgroundColor: statusDotColor,
|
||||||
|
borderColor: statusDotBorderColor ?? theme.colors.surface0,
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (presentation.kind === "draft") {
|
||||||
|
return <Pencil size={size} color={iconColor} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (presentation.kind === "file") {
|
||||||
|
return <FileText size={size} color={iconColor} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return <Terminal size={size} color={iconColor} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
type WorkspaceTabOptionRowProps = {
|
||||||
|
presentation: WorkspaceTabPresentation;
|
||||||
|
selected: boolean;
|
||||||
|
active: boolean;
|
||||||
|
onPress: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function WorkspaceTabOptionRow({
|
||||||
|
presentation,
|
||||||
|
selected,
|
||||||
|
active,
|
||||||
|
onPress,
|
||||||
|
}: WorkspaceTabOptionRowProps): ReactElement {
|
||||||
|
const { theme } = useUnistyles();
|
||||||
|
return (
|
||||||
|
<Pressable
|
||||||
|
onPress={onPress}
|
||||||
|
style={({ hovered = false, pressed }) => [
|
||||||
|
styles.optionRow,
|
||||||
|
(hovered || pressed || active) && styles.optionRowActive,
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<View style={styles.optionLeadingSlot}>
|
||||||
|
<WorkspaceTabIcon presentation={presentation} active={selected || active} />
|
||||||
|
</View>
|
||||||
|
<View style={styles.optionContent}>
|
||||||
|
<Text numberOfLines={1} style={styles.optionLabel}>
|
||||||
|
{presentation.titleState === "loading" ? "Loading..." : presentation.label}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
{selected ? (
|
||||||
|
<View style={styles.optionTrailingSlot}>
|
||||||
|
<Check size={16} color={theme.colors.foregroundMuted} />
|
||||||
|
</View>
|
||||||
|
) : null}
|
||||||
|
</Pressable>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create((theme) => ({
|
||||||
|
agentIconWrapper: {
|
||||||
|
position: "relative",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
},
|
||||||
|
statusDot: {
|
||||||
|
position: "absolute",
|
||||||
|
right: -2,
|
||||||
|
bottom: -2,
|
||||||
|
width: 7,
|
||||||
|
height: 7,
|
||||||
|
borderRadius: theme.borderRadius.full,
|
||||||
|
borderWidth: 1,
|
||||||
|
},
|
||||||
|
optionRow: {
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
minHeight: 36,
|
||||||
|
gap: theme.spacing[2],
|
||||||
|
paddingHorizontal: theme.spacing[3],
|
||||||
|
paddingVertical: theme.spacing[2],
|
||||||
|
borderRadius: 0,
|
||||||
|
marginHorizontal: theme.spacing[1],
|
||||||
|
marginBottom: theme.spacing[1],
|
||||||
|
},
|
||||||
|
optionRowActive: {
|
||||||
|
backgroundColor: theme.colors.surface1,
|
||||||
|
},
|
||||||
|
optionLeadingSlot: {
|
||||||
|
width: 16,
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
},
|
||||||
|
optionContent: {
|
||||||
|
flex: 1,
|
||||||
|
flexShrink: 1,
|
||||||
|
},
|
||||||
|
optionLabel: {
|
||||||
|
fontSize: theme.fontSize.sm,
|
||||||
|
color: theme.colors.foreground,
|
||||||
|
},
|
||||||
|
optionTrailingSlot: {
|
||||||
|
width: 16,
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
marginLeft: "auto",
|
||||||
|
},
|
||||||
|
}));
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import { useEffect } from "react";
|
||||||
|
import {
|
||||||
|
releaseWorkspaceTerminalSession,
|
||||||
|
retainWorkspaceTerminalSession,
|
||||||
|
} from "@/terminal/runtime/workspace-terminal-session";
|
||||||
|
|
||||||
|
export function useWorkspaceTerminalSessionRetention(input: {
|
||||||
|
scopeKey: string | null;
|
||||||
|
}): void {
|
||||||
|
useEffect(() => {
|
||||||
|
if (!input.scopeKey) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
retainWorkspaceTerminalSession({ scopeKey: input.scopeKey });
|
||||||
|
return () => {
|
||||||
|
releaseWorkspaceTerminalSession({ scopeKey: input.scopeKey! });
|
||||||
|
};
|
||||||
|
}, [input.scopeKey]);
|
||||||
|
}
|
||||||
@@ -44,6 +44,10 @@ export type TerminalOutputSessionConsumeInput = {
|
|||||||
sequence: number;
|
sequence: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type TerminalOutputSessionReadSnapshotInput = {
|
||||||
|
terminalId: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
export class TerminalOutputSession {
|
export class TerminalOutputSession {
|
||||||
private readonly listeners = new Set<() => void>();
|
private readonly listeners = new Set<() => void>();
|
||||||
private readonly outputPump: TerminalOutputPump;
|
private readonly outputPump: TerminalOutputPump;
|
||||||
@@ -132,6 +136,10 @@ export class TerminalOutputSession {
|
|||||||
this.outputPump.append(input);
|
this.outputPump.append(input);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
readSnapshot(input: TerminalOutputSessionReadSnapshotInput): string {
|
||||||
|
return this.outputPump.readSnapshot(input);
|
||||||
|
}
|
||||||
|
|
||||||
clearTerminal(input: { terminalId: string }): void {
|
clearTerminal(input: { terminalId: string }): void {
|
||||||
this.outputPump.clearTerminal(input);
|
this.outputPump.clearTerminal(input);
|
||||||
if (this.state.selectedTerminalId !== input.terminalId) {
|
if (this.state.selectedTerminalId !== input.terminalId) {
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import {
|
|||||||
type TerminalStreamControllerAttachPayload,
|
type TerminalStreamControllerAttachPayload,
|
||||||
type TerminalStreamControllerChunk,
|
type TerminalStreamControllerChunk,
|
||||||
type TerminalStreamControllerClient,
|
type TerminalStreamControllerClient,
|
||||||
|
type TerminalStreamControllerResumeOffsets,
|
||||||
type TerminalStreamControllerStatus,
|
type TerminalStreamControllerStatus,
|
||||||
} from "./terminal-stream-controller";
|
} from "./terminal-stream-controller";
|
||||||
|
|
||||||
@@ -112,6 +113,7 @@ class FakeTerminalStreamClient implements TerminalStreamControllerClient {
|
|||||||
|
|
||||||
function createControllerHarness(input?: {
|
function createControllerHarness(input?: {
|
||||||
client?: FakeTerminalStreamClient;
|
client?: FakeTerminalStreamClient;
|
||||||
|
resumeOffsets?: TerminalStreamControllerResumeOffsets;
|
||||||
}): {
|
}): {
|
||||||
client: FakeTerminalStreamClient;
|
client: FakeTerminalStreamClient;
|
||||||
chunks: Array<{ terminalId: string; text: string }>;
|
chunks: Array<{ terminalId: string; text: string }>;
|
||||||
@@ -127,6 +129,7 @@ function createControllerHarness(input?: {
|
|||||||
const controller = new TerminalStreamController({
|
const controller = new TerminalStreamController({
|
||||||
client,
|
client,
|
||||||
getPreferredSize: () => ({ rows: 24, cols: 80 }),
|
getPreferredSize: () => ({ rows: 24, cols: 80 }),
|
||||||
|
resumeOffsets: input?.resumeOffsets,
|
||||||
onChunk: (chunk) => {
|
onChunk: (chunk) => {
|
||||||
chunks.push({
|
chunks.push({
|
||||||
terminalId: chunk.terminalId,
|
terminalId: chunk.terminalId,
|
||||||
@@ -518,4 +521,69 @@ describe("terminal-stream-controller", () => {
|
|||||||
text: "live",
|
text: "live",
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("reuses external resume offsets across controller recreation", async () => {
|
||||||
|
const client = new FakeTerminalStreamClient();
|
||||||
|
const resumeOffsetByTerminalId = new Map<string, number>();
|
||||||
|
const resumeOffsets: TerminalStreamControllerResumeOffsets = {
|
||||||
|
get: ({ terminalId }) => resumeOffsetByTerminalId.get(terminalId),
|
||||||
|
set: ({ terminalId, offset }) => {
|
||||||
|
resumeOffsetByTerminalId.set(terminalId, offset);
|
||||||
|
},
|
||||||
|
clear: ({ terminalId }) => {
|
||||||
|
resumeOffsetByTerminalId.delete(terminalId);
|
||||||
|
},
|
||||||
|
prune: ({ terminalIds }) => {
|
||||||
|
const terminalIdSet = new Set(terminalIds);
|
||||||
|
for (const terminalId of Array.from(resumeOffsetByTerminalId.keys())) {
|
||||||
|
if (!terminalIdSet.has(terminalId)) {
|
||||||
|
resumeOffsetByTerminalId.delete(terminalId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
client.nextAttachResponses.push({
|
||||||
|
streamId: 81,
|
||||||
|
currentOffset: 0,
|
||||||
|
reset: false,
|
||||||
|
error: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
const firstHarness = createControllerHarness({
|
||||||
|
client,
|
||||||
|
resumeOffsets,
|
||||||
|
});
|
||||||
|
firstHarness.controller.setTerminal({ terminalId: "term-shared-resume" });
|
||||||
|
await flushAsyncWork();
|
||||||
|
|
||||||
|
client.emitChunk({
|
||||||
|
streamId: 81,
|
||||||
|
offset: 0,
|
||||||
|
endOffset: 2,
|
||||||
|
data: "ok",
|
||||||
|
});
|
||||||
|
await flushAsyncWork();
|
||||||
|
firstHarness.controller.dispose();
|
||||||
|
|
||||||
|
client.nextAttachResponses.push({
|
||||||
|
streamId: 82,
|
||||||
|
currentOffset: 2,
|
||||||
|
reset: false,
|
||||||
|
error: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
const secondHarness = createControllerHarness({
|
||||||
|
client,
|
||||||
|
resumeOffsets,
|
||||||
|
});
|
||||||
|
secondHarness.controller.setTerminal({ terminalId: "term-shared-resume" });
|
||||||
|
await flushAsyncWork();
|
||||||
|
|
||||||
|
expect(client.attachCalls.at(-1)?.options).toEqual({
|
||||||
|
resumeOffset: 2,
|
||||||
|
rows: 24,
|
||||||
|
cols: 80,
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import {
|
|||||||
getTerminalAttachRetryDelayMs,
|
getTerminalAttachRetryDelayMs,
|
||||||
getTerminalResumeOffset,
|
getTerminalResumeOffset,
|
||||||
isTerminalAttachRetryableError,
|
isTerminalAttachRetryableError,
|
||||||
|
type TerminalResumeOffsetStore,
|
||||||
updateTerminalResumeOffset,
|
updateTerminalResumeOffset,
|
||||||
waitForDuration,
|
waitForDuration,
|
||||||
withPromiseTimeout,
|
withPromiseTimeout,
|
||||||
@@ -51,9 +52,15 @@ export type TerminalStreamControllerStatus = {
|
|||||||
error: string | null;
|
error: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type TerminalStreamControllerResumeOffsets = TerminalResumeOffsetStore & {
|
||||||
|
clear: (input: { terminalId: string }) => void;
|
||||||
|
prune: (input: { terminalIds: string[] }) => void;
|
||||||
|
};
|
||||||
|
|
||||||
export type TerminalStreamControllerOptions = {
|
export type TerminalStreamControllerOptions = {
|
||||||
client: TerminalStreamControllerClient;
|
client: TerminalStreamControllerClient;
|
||||||
getPreferredSize: () => TerminalStreamControllerSize | null;
|
getPreferredSize: () => TerminalStreamControllerSize | null;
|
||||||
|
resumeOffsets?: TerminalStreamControllerResumeOffsets;
|
||||||
onChunk: (input: { terminalId: string; text: string; replay: boolean }) => void;
|
onChunk: (input: { terminalId: string; text: string; replay: boolean }) => void;
|
||||||
onReset?: (input: { terminalId: string }) => void;
|
onReset?: (input: { terminalId: string }) => void;
|
||||||
onStatusChange?: (status: TerminalStreamControllerStatus) => void;
|
onStatusChange?: (status: TerminalStreamControllerStatus) => void;
|
||||||
@@ -84,8 +91,8 @@ const DEFAULT_ATTACH_TIMEOUT_MS = 12_000;
|
|||||||
const DEFAULT_RECONNECT_ERROR_MESSAGE = "Terminal stream ended. Reconnecting…";
|
const DEFAULT_RECONNECT_ERROR_MESSAGE = "Terminal stream ended. Reconnecting…";
|
||||||
|
|
||||||
export class TerminalStreamController {
|
export class TerminalStreamController {
|
||||||
private readonly resumeOffsetByTerminalId = new Map<string, number>();
|
|
||||||
private readonly exitedStreamsWhileAttaching = new Set<number>();
|
private readonly exitedStreamsWhileAttaching = new Set<number>();
|
||||||
|
private readonly internalResumeOffsetByTerminalId = new Map<string, number>();
|
||||||
private selectedTerminalId: string | null = null;
|
private selectedTerminalId: string | null = null;
|
||||||
private activeStream: TerminalStreamControllerActiveStream | null = null;
|
private activeStream: TerminalStreamControllerActiveStream | null = null;
|
||||||
private attachGeneration = 0;
|
private attachGeneration = 0;
|
||||||
@@ -201,10 +208,14 @@ export class TerminalStreamController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pruneResumeOffsets(input: { terminalIds: string[] }): void {
|
pruneResumeOffsets(input: { terminalIds: string[] }): void {
|
||||||
|
if (this.options.resumeOffsets) {
|
||||||
|
this.options.resumeOffsets.prune(input);
|
||||||
|
return;
|
||||||
|
}
|
||||||
const terminalIdSet = new Set(input.terminalIds);
|
const terminalIdSet = new Set(input.terminalIds);
|
||||||
for (const terminalId of Array.from(this.resumeOffsetByTerminalId.keys())) {
|
for (const terminalId of Array.from(this.internalResumeOffsetByTerminalId.keys())) {
|
||||||
if (!terminalIdSet.has(terminalId)) {
|
if (!terminalIdSet.has(terminalId)) {
|
||||||
this.resumeOffsetByTerminalId.delete(terminalId);
|
this.internalResumeOffsetByTerminalId.delete(terminalId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -217,7 +228,7 @@ export class TerminalStreamController {
|
|||||||
this.attachGeneration += 1;
|
this.attachGeneration += 1;
|
||||||
this.selectedTerminalId = null;
|
this.selectedTerminalId = null;
|
||||||
void this.detachActiveStream({ shouldDetach: true });
|
void this.detachActiveStream({ shouldDetach: true });
|
||||||
this.resumeOffsetByTerminalId.clear();
|
this.internalResumeOffsetByTerminalId.clear();
|
||||||
this.updateStatus({
|
this.updateStatus({
|
||||||
terminalId: null,
|
terminalId: null,
|
||||||
streamId: null,
|
streamId: null,
|
||||||
@@ -260,7 +271,7 @@ export class TerminalStreamController {
|
|||||||
const preferredSize = this.options.getPreferredSize();
|
const preferredSize = this.options.getPreferredSize();
|
||||||
const resumeOffset = getTerminalResumeOffset({
|
const resumeOffset = getTerminalResumeOffset({
|
||||||
terminalId: input.terminalId,
|
terminalId: input.terminalId,
|
||||||
resumeOffsetByTerminalId: this.resumeOffsetByTerminalId,
|
resumeOffsetStore: this.getResumeOffsetStore(),
|
||||||
});
|
});
|
||||||
const attachPayload = await withTimeout({
|
const attachPayload = await withTimeout({
|
||||||
promise: this.options.client.attachTerminalStream(input.terminalId, {
|
promise: this.options.client.attachTerminalStream(input.terminalId, {
|
||||||
@@ -276,7 +287,7 @@ export class TerminalStreamController {
|
|||||||
updateTerminalResumeOffset({
|
updateTerminalResumeOffset({
|
||||||
terminalId: input.terminalId,
|
terminalId: input.terminalId,
|
||||||
offset: attachPayload.currentOffset,
|
offset: attachPayload.currentOffset,
|
||||||
resumeOffsetByTerminalId: this.resumeOffsetByTerminalId,
|
resumeOffsetStore: this.getResumeOffsetStore(),
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!this.isAttachGenerationCurrent({ generation: input.generation, terminalId: input.terminalId })) {
|
if (!this.isAttachGenerationCurrent({ generation: input.generation, terminalId: input.terminalId })) {
|
||||||
@@ -324,7 +335,7 @@ export class TerminalStreamController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (attachPayload.reset) {
|
if (attachPayload.reset) {
|
||||||
this.resumeOffsetByTerminalId.delete(input.terminalId);
|
this.options.resumeOffsets?.clear({ terminalId: input.terminalId });
|
||||||
this.options.onReset?.({ terminalId: input.terminalId });
|
this.options.onReset?.({ terminalId: input.terminalId });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -492,7 +503,7 @@ export class TerminalStreamController {
|
|||||||
updateTerminalResumeOffset({
|
updateTerminalResumeOffset({
|
||||||
terminalId: input.terminalId,
|
terminalId: input.terminalId,
|
||||||
offset: chunkEndOffset,
|
offset: chunkEndOffset,
|
||||||
resumeOffsetByTerminalId: this.resumeOffsetByTerminalId,
|
resumeOffsetStore: this.getResumeOffsetStore(),
|
||||||
});
|
});
|
||||||
|
|
||||||
const text = input.decoder.decode(input.chunk.data, { stream: true });
|
const text = input.decoder.decode(input.chunk.data, { stream: true });
|
||||||
@@ -541,7 +552,7 @@ export class TerminalStreamController {
|
|||||||
updateTerminalResumeOffset({
|
updateTerminalResumeOffset({
|
||||||
terminalId: input.terminalId,
|
terminalId: input.terminalId,
|
||||||
offset: input.expectedOffset,
|
offset: input.expectedOffset,
|
||||||
resumeOffsetByTerminalId: this.resumeOffsetByTerminalId,
|
resumeOffsetStore: this.getResumeOffsetStore(),
|
||||||
});
|
});
|
||||||
|
|
||||||
this.attachGeneration += 1;
|
this.attachGeneration += 1;
|
||||||
@@ -634,4 +645,16 @@ export class TerminalStreamController {
|
|||||||
});
|
});
|
||||||
this.options.onStatusChange?.(status);
|
this.options.onStatusChange?.(status);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private getResumeOffsetStore(): TerminalResumeOffsetStore {
|
||||||
|
if (this.options.resumeOffsets) {
|
||||||
|
return this.options.resumeOffsets;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
get: ({ terminalId }) => this.internalResumeOffsetByTerminalId.get(terminalId),
|
||||||
|
set: ({ terminalId, offset }) => {
|
||||||
|
this.internalResumeOffsetByTerminalId.set(terminalId, offset);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,63 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
import {
|
||||||
|
getWorkspaceTerminalSession,
|
||||||
|
releaseWorkspaceTerminalSession,
|
||||||
|
retainWorkspaceTerminalSession,
|
||||||
|
} from "./workspace-terminal-session";
|
||||||
|
|
||||||
|
describe("workspace-terminal-session", () => {
|
||||||
|
it("returns the same workspace session instance for the same scope", () => {
|
||||||
|
const first = getWorkspaceTerminalSession({
|
||||||
|
scopeKey: "workspace-a",
|
||||||
|
maxOutputChars: 1_000,
|
||||||
|
});
|
||||||
|
const second = getWorkspaceTerminalSession({
|
||||||
|
scopeKey: "workspace-a",
|
||||||
|
maxOutputChars: 50,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(second).toBe(first);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("preserves resume offsets across repeated lookups", () => {
|
||||||
|
const first = getWorkspaceTerminalSession({
|
||||||
|
scopeKey: "workspace-resume",
|
||||||
|
maxOutputChars: 1_000,
|
||||||
|
});
|
||||||
|
first.resumeOffsets.set({
|
||||||
|
terminalId: "term-1",
|
||||||
|
offset: 42,
|
||||||
|
});
|
||||||
|
|
||||||
|
const second = getWorkspaceTerminalSession({
|
||||||
|
scopeKey: "workspace-resume",
|
||||||
|
maxOutputChars: 1_000,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(second.resumeOffsets.get({ terminalId: "term-1" })).toBe(42);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("evicts workspace terminal session state when the workspace retain count returns to zero", () => {
|
||||||
|
const scopeKey = "workspace-release";
|
||||||
|
const first = getWorkspaceTerminalSession({
|
||||||
|
scopeKey,
|
||||||
|
maxOutputChars: 1_000,
|
||||||
|
});
|
||||||
|
first.resumeOffsets.set({
|
||||||
|
terminalId: "term-1",
|
||||||
|
offset: 128,
|
||||||
|
});
|
||||||
|
|
||||||
|
retainWorkspaceTerminalSession({ scopeKey });
|
||||||
|
releaseWorkspaceTerminalSession({ scopeKey });
|
||||||
|
|
||||||
|
const second = getWorkspaceTerminalSession({
|
||||||
|
scopeKey,
|
||||||
|
maxOutputChars: 1_000,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(second).not.toBe(first);
|
||||||
|
expect(second.resumeOffsets.get({ terminalId: "term-1" })).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
import {
|
||||||
|
getTerminalOutputSession,
|
||||||
|
releaseTerminalOutputSession,
|
||||||
|
retainTerminalOutputSession,
|
||||||
|
type TerminalOutputSession,
|
||||||
|
} from "./terminal-output-session";
|
||||||
|
|
||||||
|
export type WorkspaceTerminalResumeOffsets = {
|
||||||
|
get: (input: { terminalId: string }) => number | undefined;
|
||||||
|
set: (input: { terminalId: string; offset: number }) => void;
|
||||||
|
clear: (input: { terminalId: string }) => void;
|
||||||
|
prune: (input: { terminalIds: string[] }) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type WorkspaceTerminalSession = {
|
||||||
|
scopeKey: string;
|
||||||
|
outputSession: TerminalOutputSession;
|
||||||
|
resumeOffsets: WorkspaceTerminalResumeOffsets;
|
||||||
|
};
|
||||||
|
|
||||||
|
type WorkspaceTerminalSessionRecord = {
|
||||||
|
resumeOffsetByTerminalId: Map<string, number>;
|
||||||
|
session: WorkspaceTerminalSession;
|
||||||
|
};
|
||||||
|
|
||||||
|
const sessionsByScopeKey = new Map<string, WorkspaceTerminalSessionRecord>();
|
||||||
|
const refCountByScopeKey = new Map<string, number>();
|
||||||
|
|
||||||
|
function createResumeOffsets(input: {
|
||||||
|
resumeOffsetByTerminalId: Map<string, number>;
|
||||||
|
}): WorkspaceTerminalResumeOffsets {
|
||||||
|
return {
|
||||||
|
get: ({ terminalId }) => {
|
||||||
|
const offset = input.resumeOffsetByTerminalId.get(terminalId);
|
||||||
|
if (typeof offset !== "number" || !Number.isFinite(offset)) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
return Math.max(0, Math.floor(offset));
|
||||||
|
},
|
||||||
|
set: ({ terminalId, offset }) => {
|
||||||
|
if (!Number.isFinite(offset)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
input.resumeOffsetByTerminalId.set(terminalId, Math.max(0, Math.floor(offset)));
|
||||||
|
},
|
||||||
|
clear: ({ terminalId }) => {
|
||||||
|
input.resumeOffsetByTerminalId.delete(terminalId);
|
||||||
|
},
|
||||||
|
prune: ({ terminalIds }) => {
|
||||||
|
const terminalIdSet = new Set(terminalIds);
|
||||||
|
for (const terminalId of Array.from(input.resumeOffsetByTerminalId.keys())) {
|
||||||
|
if (!terminalIdSet.has(terminalId)) {
|
||||||
|
input.resumeOffsetByTerminalId.delete(terminalId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getWorkspaceTerminalSession(input: {
|
||||||
|
scopeKey: string;
|
||||||
|
maxOutputChars: number;
|
||||||
|
}): WorkspaceTerminalSession {
|
||||||
|
const existing = sessionsByScopeKey.get(input.scopeKey);
|
||||||
|
if (existing) {
|
||||||
|
return existing.session;
|
||||||
|
}
|
||||||
|
|
||||||
|
const resumeOffsetByTerminalId = new Map<string, number>();
|
||||||
|
const session: WorkspaceTerminalSession = {
|
||||||
|
scopeKey: input.scopeKey,
|
||||||
|
outputSession: getTerminalOutputSession({
|
||||||
|
scopeKey: input.scopeKey,
|
||||||
|
maxOutputChars: input.maxOutputChars,
|
||||||
|
}),
|
||||||
|
resumeOffsets: createResumeOffsets({
|
||||||
|
resumeOffsetByTerminalId,
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
|
sessionsByScopeKey.set(input.scopeKey, {
|
||||||
|
resumeOffsetByTerminalId,
|
||||||
|
session,
|
||||||
|
});
|
||||||
|
return session;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function retainWorkspaceTerminalSession(input: { scopeKey: string }): void {
|
||||||
|
const current = refCountByScopeKey.get(input.scopeKey) ?? 0;
|
||||||
|
refCountByScopeKey.set(input.scopeKey, current + 1);
|
||||||
|
retainTerminalOutputSession(input);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function releaseWorkspaceTerminalSession(input: { scopeKey: string }): void {
|
||||||
|
releaseTerminalOutputSession(input);
|
||||||
|
const current = refCountByScopeKey.get(input.scopeKey) ?? 0;
|
||||||
|
if (current > 1) {
|
||||||
|
refCountByScopeKey.set(input.scopeKey, current - 1);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
refCountByScopeKey.delete(input.scopeKey);
|
||||||
|
sessionsByScopeKey.delete(input.scopeKey);
|
||||||
|
}
|
||||||
@@ -9,7 +9,7 @@ describe("decideLongPressMove", () => {
|
|||||||
it("keeps long press pending for small movement before long-press arm", () => {
|
it("keeps long press pending for small movement before long-press arm", () => {
|
||||||
expect(
|
expect(
|
||||||
decideLongPressMove({
|
decideLongPressMove({
|
||||||
longPressArmed: false,
|
dragArmed: false,
|
||||||
didStartDrag: false,
|
didStartDrag: false,
|
||||||
startPoint: { x: 0, y: 0 },
|
startPoint: { x: 0, y: 0 },
|
||||||
currentPoint: { x: 3, y: 2 },
|
currentPoint: { x: 3, y: 2 },
|
||||||
@@ -20,18 +20,51 @@ describe("decideLongPressMove", () => {
|
|||||||
it("cancels long press when movement exceeds cancel slop before arm", () => {
|
it("cancels long press when movement exceeds cancel slop before arm", () => {
|
||||||
expect(
|
expect(
|
||||||
decideLongPressMove({
|
decideLongPressMove({
|
||||||
longPressArmed: false,
|
dragArmed: false,
|
||||||
didStartDrag: false,
|
didStartDrag: false,
|
||||||
startPoint: { x: 0, y: 0 },
|
startPoint: { x: 0, y: 0 },
|
||||||
currentPoint: { x: 12, y: 0 },
|
currentPoint: { x: 8, y: 8 },
|
||||||
})
|
})
|
||||||
).toBe("cancel_long_press");
|
).toBe("cancel_long_press");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("yields to vertical scroll before drag arm", () => {
|
||||||
|
expect(
|
||||||
|
decideLongPressMove({
|
||||||
|
dragArmed: false,
|
||||||
|
didStartDrag: false,
|
||||||
|
startPoint: { x: 0, y: 0 },
|
||||||
|
currentPoint: { x: 2, y: 7 },
|
||||||
|
})
|
||||||
|
).toBe("vertical_scroll");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps diagonal motion neutral before drag arm", () => {
|
||||||
|
expect(
|
||||||
|
decideLongPressMove({
|
||||||
|
dragArmed: false,
|
||||||
|
didStartDrag: false,
|
||||||
|
startPoint: { x: 0, y: 0 },
|
||||||
|
currentPoint: { x: 5, y: 7 },
|
||||||
|
})
|
||||||
|
).toBe("none");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("yields to horizontal swipe before drag arm", () => {
|
||||||
|
expect(
|
||||||
|
decideLongPressMove({
|
||||||
|
dragArmed: false,
|
||||||
|
didStartDrag: false,
|
||||||
|
startPoint: { x: 0, y: 0 },
|
||||||
|
currentPoint: { x: -10, y: 2 },
|
||||||
|
})
|
||||||
|
).toBe("horizontal_swipe");
|
||||||
|
});
|
||||||
|
|
||||||
it("starts drag when movement exceeds drag slop after long-press arm", () => {
|
it("starts drag when movement exceeds drag slop after long-press arm", () => {
|
||||||
expect(
|
expect(
|
||||||
decideLongPressMove({
|
decideLongPressMove({
|
||||||
longPressArmed: true,
|
dragArmed: true,
|
||||||
didStartDrag: false,
|
didStartDrag: false,
|
||||||
startPoint: { x: 0, y: 0 },
|
startPoint: { x: 0, y: 0 },
|
||||||
currentPoint: { x: 0, y: 9 },
|
currentPoint: { x: 0, y: 9 },
|
||||||
@@ -42,7 +75,7 @@ describe("decideLongPressMove", () => {
|
|||||||
it("does nothing when drag already started", () => {
|
it("does nothing when drag already started", () => {
|
||||||
expect(
|
expect(
|
||||||
decideLongPressMove({
|
decideLongPressMove({
|
||||||
longPressArmed: true,
|
dragArmed: true,
|
||||||
didStartDrag: true,
|
didStartDrag: true,
|
||||||
startPoint: { x: 0, y: 0 },
|
startPoint: { x: 0, y: 0 },
|
||||||
currentPoint: { x: 20, y: 20 },
|
currentPoint: { x: 20, y: 20 },
|
||||||
@@ -75,4 +108,3 @@ describe("shouldOpenContextMenuOnPressOut", () => {
|
|||||||
).toBe(false);
|
).toBe(false);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,14 +1,25 @@
|
|||||||
export type LongPressMoveDecision = "none" | "cancel_long_press" | "start_drag";
|
export type LongPressMoveDecision =
|
||||||
|
| "none"
|
||||||
|
| "cancel_long_press"
|
||||||
|
| "vertical_scroll"
|
||||||
|
| "horizontal_swipe"
|
||||||
|
| "start_drag";
|
||||||
|
|
||||||
export function decideLongPressMove(input: {
|
export function decideLongPressMove(input: {
|
||||||
longPressArmed: boolean;
|
dragArmed: boolean;
|
||||||
didStartDrag: boolean;
|
didStartDrag: boolean;
|
||||||
startPoint: { x: number; y: number } | null;
|
startPoint: { x: number; y: number } | null;
|
||||||
currentPoint: { x: number; y: number };
|
currentPoint: { x: number; y: number };
|
||||||
cancelSlopPx?: number;
|
cancelSlopPx?: number;
|
||||||
|
scrollSlopPx?: number;
|
||||||
|
swipeSlopPx?: number;
|
||||||
|
directionalDominanceRatio?: number;
|
||||||
dragSlopPx?: number;
|
dragSlopPx?: number;
|
||||||
}): LongPressMoveDecision {
|
}): LongPressMoveDecision {
|
||||||
const cancelSlopPx = input.cancelSlopPx ?? 10;
|
const cancelSlopPx = input.cancelSlopPx ?? 10;
|
||||||
|
const scrollSlopPx = input.scrollSlopPx ?? 6;
|
||||||
|
const swipeSlopPx = input.swipeSlopPx ?? 8;
|
||||||
|
const directionalDominanceRatio = input.directionalDominanceRatio ?? 1.5;
|
||||||
const dragSlopPx = input.dragSlopPx ?? 8;
|
const dragSlopPx = input.dragSlopPx ?? 8;
|
||||||
|
|
||||||
if (!input.startPoint) {
|
if (!input.startPoint) {
|
||||||
@@ -20,9 +31,19 @@ export function decideLongPressMove(input: {
|
|||||||
|
|
||||||
const dx = input.currentPoint.x - input.startPoint.x;
|
const dx = input.currentPoint.x - input.startPoint.x;
|
||||||
const dy = input.currentPoint.y - input.startPoint.y;
|
const dy = input.currentPoint.y - input.startPoint.y;
|
||||||
|
const absDx = Math.abs(dx);
|
||||||
|
const absDy = Math.abs(dy);
|
||||||
const distance = Math.sqrt(dx * dx + dy * dy);
|
const distance = Math.sqrt(dx * dx + dy * dy);
|
||||||
|
const clearlyVertical = absDy >= absDx * directionalDominanceRatio;
|
||||||
|
const clearlyHorizontal = absDx >= absDy * directionalDominanceRatio;
|
||||||
|
|
||||||
if (!input.longPressArmed) {
|
if (!input.dragArmed) {
|
||||||
|
if (clearlyVertical && absDy > scrollSlopPx) {
|
||||||
|
return "vertical_scroll";
|
||||||
|
}
|
||||||
|
if (clearlyHorizontal && absDx > swipeSlopPx) {
|
||||||
|
return "horizontal_swipe";
|
||||||
|
}
|
||||||
return distance > cancelSlopPx ? "cancel_long_press" : "none";
|
return distance > cancelSlopPx ? "cancel_long_press" : "none";
|
||||||
}
|
}
|
||||||
return distance > dragSlopPx ? "start_drag" : "none";
|
return distance > dragSlopPx ? "start_drag" : "none";
|
||||||
@@ -34,4 +55,3 @@ export function shouldOpenContextMenuOnPressOut(input: {
|
|||||||
}): boolean {
|
}): boolean {
|
||||||
return input.longPressArmed && !input.didStartDrag;
|
return input.longPressArmed && !input.didStartDrag;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -35,23 +35,29 @@ describe("terminal-attach", () => {
|
|||||||
it("reads and updates resume offsets monotonically", () => {
|
it("reads and updates resume offsets monotonically", () => {
|
||||||
const offsets = new Map<string, number>();
|
const offsets = new Map<string, number>();
|
||||||
const terminalId = "term-1";
|
const terminalId = "term-1";
|
||||||
|
const resumeOffsetStore = {
|
||||||
|
get: ({ terminalId }: { terminalId: string }) => offsets.get(terminalId),
|
||||||
|
set: ({ terminalId, offset }: { terminalId: string; offset: number }) => {
|
||||||
|
offsets.set(terminalId, offset);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
expect(
|
expect(
|
||||||
getTerminalResumeOffset({
|
getTerminalResumeOffset({
|
||||||
terminalId,
|
terminalId,
|
||||||
resumeOffsetByTerminalId: offsets,
|
resumeOffsetStore,
|
||||||
})
|
})
|
||||||
).toBeUndefined();
|
).toBeUndefined();
|
||||||
|
|
||||||
updateTerminalResumeOffset({
|
updateTerminalResumeOffset({
|
||||||
terminalId,
|
terminalId,
|
||||||
offset: 8,
|
offset: 8,
|
||||||
resumeOffsetByTerminalId: offsets,
|
resumeOffsetStore,
|
||||||
});
|
});
|
||||||
expect(
|
expect(
|
||||||
getTerminalResumeOffset({
|
getTerminalResumeOffset({
|
||||||
terminalId,
|
terminalId,
|
||||||
resumeOffsetByTerminalId: offsets,
|
resumeOffsetStore,
|
||||||
})
|
})
|
||||||
).toBe(8);
|
).toBe(8);
|
||||||
|
|
||||||
@@ -59,12 +65,12 @@ describe("terminal-attach", () => {
|
|||||||
updateTerminalResumeOffset({
|
updateTerminalResumeOffset({
|
||||||
terminalId,
|
terminalId,
|
||||||
offset: 3,
|
offset: 3,
|
||||||
resumeOffsetByTerminalId: offsets,
|
resumeOffsetStore,
|
||||||
});
|
});
|
||||||
expect(
|
expect(
|
||||||
getTerminalResumeOffset({
|
getTerminalResumeOffset({
|
||||||
terminalId,
|
terminalId,
|
||||||
resumeOffsetByTerminalId: offsets,
|
resumeOffsetStore,
|
||||||
})
|
})
|
||||||
).toBe(8);
|
).toBe(8);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -8,11 +8,16 @@ const TERMINAL_ATTACH_RETRYABLE_ERROR_PATTERNS = [
|
|||||||
"stream ended",
|
"stream ended",
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
|
export type TerminalResumeOffsetStore = {
|
||||||
|
get: (input: { terminalId: string }) => number | undefined;
|
||||||
|
set: (input: { terminalId: string; offset: number }) => void;
|
||||||
|
};
|
||||||
|
|
||||||
export function getTerminalResumeOffset(input: {
|
export function getTerminalResumeOffset(input: {
|
||||||
terminalId: string;
|
terminalId: string;
|
||||||
resumeOffsetByTerminalId: Map<string, number>;
|
resumeOffsetStore: TerminalResumeOffsetStore;
|
||||||
}): number | undefined {
|
}): number | undefined {
|
||||||
const offset = input.resumeOffsetByTerminalId.get(input.terminalId);
|
const offset = input.resumeOffsetStore.get({ terminalId: input.terminalId });
|
||||||
if (typeof offset !== "number" || !Number.isFinite(offset)) {
|
if (typeof offset !== "number" || !Number.isFinite(offset)) {
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
@@ -24,7 +29,7 @@ export function getTerminalResumeOffset(input: {
|
|||||||
export function updateTerminalResumeOffset(input: {
|
export function updateTerminalResumeOffset(input: {
|
||||||
terminalId: string;
|
terminalId: string;
|
||||||
offset: number;
|
offset: number;
|
||||||
resumeOffsetByTerminalId: Map<string, number>;
|
resumeOffsetStore: TerminalResumeOffsetStore;
|
||||||
}): void {
|
}): void {
|
||||||
if (!Number.isFinite(input.offset)) {
|
if (!Number.isFinite(input.offset)) {
|
||||||
return;
|
return;
|
||||||
@@ -34,13 +39,16 @@ export function updateTerminalResumeOffset(input: {
|
|||||||
const previousOffset =
|
const previousOffset =
|
||||||
getTerminalResumeOffset({
|
getTerminalResumeOffset({
|
||||||
terminalId: input.terminalId,
|
terminalId: input.terminalId,
|
||||||
resumeOffsetByTerminalId: input.resumeOffsetByTerminalId,
|
resumeOffsetStore: input.resumeOffsetStore,
|
||||||
}) ?? -1;
|
}) ?? -1;
|
||||||
if (normalizedOffset <= previousOffset) {
|
if (normalizedOffset <= previousOffset) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
input.resumeOffsetByTerminalId.set(input.terminalId, normalizedOffset);
|
input.resumeOffsetStore.set({
|
||||||
|
terminalId: input.terminalId,
|
||||||
|
offset: normalizedOffset,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getTerminalAttachRetryDelayMs(input: { attempt: number }): number {
|
export function getTerminalAttachRetryDelayMs(input: { attempt: number }): number {
|
||||||
|
|||||||
@@ -16,3 +16,23 @@ index 7c88afc..b630940 100644
|
|||||||
|
|
||||||
const onScroll = useStableCallback((scrollOffset: number) => {
|
const onScroll = useStableCallback((scrollOffset: number) => {
|
||||||
props.onScrollOffsetChange?.(scrollOffset);
|
props.onScrollOffsetChange?.(scrollOffset);
|
||||||
|
diff --git a/node_modules/react-native-draggable-flatlist/src/components/NestableDraggableFlatList.tsx b/node_modules/react-native-draggable-flatlist/src/components/NestableDraggableFlatList.tsx
|
||||||
|
index 1559352..99d7a9b 100644
|
||||||
|
--- a/node_modules/react-native-draggable-flatlist/src/components/NestableDraggableFlatList.tsx
|
||||||
|
+++ b/node_modules/react-native-draggable-flatlist/src/components/NestableDraggableFlatList.tsx
|
||||||
|
@@ -1,5 +1,5 @@
|
||||||
|
import React, { useRef, useState } from "react";
|
||||||
|
-import { findNodeHandle, LogBox } from "react-native";
|
||||||
|
+import { LogBox } from "react-native";
|
||||||
|
import Animated, {
|
||||||
|
useDerivedValue,
|
||||||
|
useSharedValue,
|
||||||
|
@@ -48,7 +48,7 @@ function NestableDraggableFlatListInner<T>(
|
||||||
|
});
|
||||||
|
|
||||||
|
const onListContainerLayout = useStableCallback(async ({ containerRef }) => {
|
||||||
|
- const nodeHandle = findNodeHandle(scrollableRef.current);
|
||||||
|
+ const nodeHandle = scrollableRef.current;
|
||||||
|
|
||||||
|
const onSuccess = (_x: number, y: number) => {
|
||||||
|
listVerticalOffset.value = y;
|
||||||
|
|||||||
Reference in New Issue
Block a user