feat: refactor sidebar to nested draggable lists with long-press gesture arbitration
This commit is contained in:
@@ -1,6 +1,7 @@
|
|||||||
import { RefreshControl } from "react-native";
|
import { RefreshControl } from "react-native";
|
||||||
import { useCallback, useState } from "react";
|
import { useCallback, useState } from "react";
|
||||||
import DraggableFlatList, {
|
import DraggableFlatList, {
|
||||||
|
NestableDraggableFlatList,
|
||||||
type RenderItemParams,
|
type RenderItemParams,
|
||||||
} from "react-native-draggable-flatlist";
|
} from "react-native-draggable-flatlist";
|
||||||
import { useUnistyles } from "react-native-unistyles";
|
import { useUnistyles } from "react-native-unistyles";
|
||||||
@@ -32,6 +33,7 @@ export function DraggableList<T>({
|
|||||||
simultaneousGestureRef,
|
simultaneousGestureRef,
|
||||||
waitFor,
|
waitFor,
|
||||||
onDragBegin: onDragBeginProp,
|
onDragBegin: onDragBeginProp,
|
||||||
|
nestable = false,
|
||||||
}: DraggableListProps<T>) {
|
}: DraggableListProps<T>) {
|
||||||
const { theme } = useUnistyles();
|
const { theme } = useUnistyles();
|
||||||
const [isDragging, setIsDragging] = useState(false);
|
const [isDragging, setIsDragging] = useState(false);
|
||||||
@@ -75,9 +77,13 @@ export function DraggableList<T>({
|
|||||||
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 ListComponent: typeof DraggableFlatList = (nestable
|
||||||
|
? (NestableDraggableFlatList as any)
|
||||||
|
: DraggableFlatList) as any;
|
||||||
|
const shouldShowRefreshControl = showRefreshControl && !nestable;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DraggableFlatList
|
<ListComponent
|
||||||
testID={testID}
|
testID={testID}
|
||||||
data={data}
|
data={data}
|
||||||
keyExtractor={keyExtractor}
|
keyExtractor={keyExtractor}
|
||||||
@@ -99,7 +105,7 @@ export function DraggableList<T>({
|
|||||||
// @ts-expect-error - waitFor is supported by RNGH FlatList but not typed in DraggableFlatList
|
// @ts-expect-error - waitFor is supported by RNGH FlatList but not typed in DraggableFlatList
|
||||||
waitFor={waitFor}
|
waitFor={waitFor}
|
||||||
refreshControl={
|
refreshControl={
|
||||||
showRefreshControl ? (
|
shouldShowRefreshControl ? (
|
||||||
<RefreshControl
|
<RefreshControl
|
||||||
refreshing={refreshing ?? false}
|
refreshing={refreshing ?? false}
|
||||||
onRefresh={onRefresh}
|
onRefresh={onRefresh}
|
||||||
|
|||||||
@@ -52,4 +52,9 @@ export interface DraggableListProps<T> {
|
|||||||
waitFor?: MutableRefObject<GestureType | undefined> | MutableRefObject<GestureType | undefined>[];
|
waitFor?: MutableRefObject<GestureType | undefined> | MutableRefObject<GestureType | undefined>[];
|
||||||
/** Called when a drag gesture begins (before items are reordered) */
|
/** Called when a drag gesture begins (before items are reordered) */
|
||||||
onDragBegin?: () => void;
|
onDragBegin?: () => void;
|
||||||
|
/**
|
||||||
|
* Native-only: use the nestable draggable-flatlist variant for nested drag
|
||||||
|
* lists coordinated by a shared NestableScrollContainer.
|
||||||
|
*/
|
||||||
|
nestable?: boolean;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -136,6 +136,7 @@ export function DraggableList<T>({
|
|||||||
useDragHandle = false,
|
useDragHandle = false,
|
||||||
// simultaneousGestureRef is native-only, ignored on web
|
// simultaneousGestureRef is native-only, ignored on web
|
||||||
onDragBegin,
|
onDragBegin,
|
||||||
|
nestable: _nestable = false,
|
||||||
}: DraggableListProps<T>) {
|
}: DraggableListProps<T>) {
|
||||||
const [activeId, setActiveId] = useState<string | null>(null);
|
const [activeId, setActiveId] = useState<string | null>(null);
|
||||||
const [items, setItems] = useState(data);
|
const [items, setItems] = useState(data);
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -18,7 +18,7 @@ import {
|
|||||||
resolveNewAgentWorkingDir,
|
resolveNewAgentWorkingDir,
|
||||||
} from "@/utils/new-agent-routing";
|
} from "@/utils/new-agent-routing";
|
||||||
import {
|
import {
|
||||||
buildHostAgentDetailRoute,
|
buildHostWorkspaceAgentTabRoute,
|
||||||
buildHostSettingsRoute,
|
buildHostSettingsRoute,
|
||||||
parseHostAgentRouteFromPathname,
|
parseHostAgentRouteFromPathname,
|
||||||
parseServerIdFromPathname,
|
parseServerIdFromPathname,
|
||||||
@@ -230,7 +230,9 @@ export function useCommandCenter() {
|
|||||||
// Don't restore focus back to the prior element after we navigate.
|
// Don't restore focus back to the prior element after we navigate.
|
||||||
clearCommandCenterFocusRestoreElement();
|
clearCommandCenterFocusRestoreElement();
|
||||||
setOpen(false);
|
setOpen(false);
|
||||||
navigate(buildHostAgentDetailRoute(agent.serverId, agent.id) as any);
|
navigate(
|
||||||
|
buildHostWorkspaceAgentTabRoute(agent.serverId, agent.cwd, agent.id) as any
|
||||||
|
);
|
||||||
},
|
},
|
||||||
[pathname, requestMessageInputAction, setOpen]
|
[pathname, requestMessageInputAction, setOpen]
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -48,7 +48,7 @@ import type {
|
|||||||
AgentSessionConfig,
|
AgentSessionConfig,
|
||||||
} from '@server/server/agent/agent-sdk-types'
|
} from '@server/server/agent/agent-sdk-types'
|
||||||
import { AGENT_PROVIDER_DEFINITIONS } from '@server/server/agent/provider-manifest'
|
import { AGENT_PROVIDER_DEFINITIONS } from '@server/server/agent/provider-manifest'
|
||||||
import { buildHostAgentDetailRoute } from '@/utils/host-routes'
|
import { buildHostWorkspaceAgentTabRoute } from '@/utils/host-routes'
|
||||||
import { useTauriDragHandlers } from '@/utils/tauri-window'
|
import { useTauriDragHandlers } from '@/utils/tauri-window'
|
||||||
import { useKeyboardShiftStyle } from '@/hooks/use-keyboard-shift-style'
|
import { useKeyboardShiftStyle } from '@/hooks/use-keyboard-shift-style'
|
||||||
import { normalizeAgentSnapshot } from '@/utils/agent-snapshots'
|
import { normalizeAgentSnapshot } from '@/utils/agent-snapshots'
|
||||||
@@ -1032,7 +1032,14 @@ function DraftAgentScreenContent({
|
|||||||
next.set(agentId, normalizeAgentSnapshot(result, selectedServerId))
|
next.set(agentId, normalizeAgentSnapshot(result, selectedServerId))
|
||||||
return next
|
return next
|
||||||
})
|
})
|
||||||
router.replace(buildHostAgentDetailRoute(selectedServerId, agentId) as any)
|
const createdWorkingDir =
|
||||||
|
typeof result.cwd === 'string' ? result.cwd.trim() : ''
|
||||||
|
const configuredWorkingDir = config.cwd.trim()
|
||||||
|
const workspaceId =
|
||||||
|
createdWorkingDir.length > 0 ? createdWorkingDir : configuredWorkingDir
|
||||||
|
router.replace(
|
||||||
|
buildHostWorkspaceAgentTabRoute(selectedServerId, workspaceId, agentId) as any
|
||||||
|
)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -177,7 +177,7 @@ export function WorkspaceDraftAgentTab({
|
|||||||
pendingPermissions: [],
|
pendingPermissions: [],
|
||||||
persistence: null,
|
persistence: null,
|
||||||
runtimeInfo: { provider: selectedProvider, sessionId: null, model, modeId },
|
runtimeInfo: { provider: selectedProvider, sessionId: null, model, modeId },
|
||||||
title: "New agent",
|
title: "Agent",
|
||||||
cwd: workspaceId,
|
cwd: workspaceId,
|
||||||
model,
|
model,
|
||||||
thinkingOptionId,
|
thinkingOptionId,
|
||||||
|
|||||||
@@ -16,7 +16,6 @@ import {
|
|||||||
FileText,
|
FileText,
|
||||||
Folder,
|
Folder,
|
||||||
GitBranch,
|
GitBranch,
|
||||||
MoreVertical,
|
|
||||||
PanelRight,
|
PanelRight,
|
||||||
Plus,
|
Plus,
|
||||||
Pencil,
|
Pencil,
|
||||||
@@ -31,12 +30,6 @@ 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 { ClaudeIcon } from "@/components/icons/claude-icon";
|
||||||
import { CodexIcon } from "@/components/icons/codex-icon";
|
import { CodexIcon } from "@/components/icons/codex-icon";
|
||||||
import {
|
|
||||||
DropdownMenu,
|
|
||||||
DropdownMenuContent,
|
|
||||||
DropdownMenuItem,
|
|
||||||
DropdownMenuTrigger,
|
|
||||||
} from "@/components/ui/dropdown-menu";
|
|
||||||
import {
|
import {
|
||||||
Tooltip,
|
Tooltip,
|
||||||
TooltipContent,
|
TooltipContent,
|
||||||
@@ -60,7 +53,6 @@ import {
|
|||||||
type WorkspaceTab,
|
type WorkspaceTab,
|
||||||
} from "@/stores/workspace-tabs-store";
|
} from "@/stores/workspace-tabs-store";
|
||||||
import {
|
import {
|
||||||
buildHostAgentDetailRoute,
|
|
||||||
buildHostWorkspaceRoute,
|
buildHostWorkspaceRoute,
|
||||||
buildHostWorkspaceTabRoute,
|
buildHostWorkspaceTabRoute,
|
||||||
decodeWorkspaceIdFromPathSegment,
|
decodeWorkspaceIdFromPathSegment,
|
||||||
@@ -88,7 +80,6 @@ import {
|
|||||||
} from "@/screens/workspace/workspace-header-source";
|
} from "@/screens/workspace/workspace-header-source";
|
||||||
|
|
||||||
const TERMINALS_QUERY_STALE_TIME = 5_000;
|
const TERMINALS_QUERY_STALE_TIME = 5_000;
|
||||||
const DROPDOWN_WIDTH = 220;
|
|
||||||
const NEW_TAB_AGENT_OPTION_ID = "__new_tab_agent__";
|
const NEW_TAB_AGENT_OPTION_ID = "__new_tab_agent__";
|
||||||
const NEW_TAB_TERMINAL_OPTION_ID = "__new_tab_terminal__";
|
const NEW_TAB_TERMINAL_OPTION_ID = "__new_tab_terminal__";
|
||||||
const EMPTY_OPEN_FILE_PATHS: string[] = [];
|
const EMPTY_OPEN_FILE_PATHS: string[] = [];
|
||||||
@@ -166,6 +157,20 @@ function formatProviderLabel(provider: Agent["provider"]): string {
|
|||||||
return provider.charAt(0).toUpperCase() + provider.slice(1);
|
return provider.charAt(0).toUpperCase() + provider.slice(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function resolveWorkspaceAgentTabLabel(title: string | null | undefined): string {
|
||||||
|
if (typeof title !== "string") {
|
||||||
|
return "Agent";
|
||||||
|
}
|
||||||
|
const normalized = title.trim();
|
||||||
|
if (!normalized) {
|
||||||
|
return "Agent";
|
||||||
|
}
|
||||||
|
if (normalized.toLowerCase() === "new agent") {
|
||||||
|
return "Agent";
|
||||||
|
}
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
|
||||||
function resolveTabAvailability(input: {
|
function resolveTabAvailability(input: {
|
||||||
tab: WorkspaceTabTarget;
|
tab: WorkspaceTabTarget;
|
||||||
agentsHydrated: boolean;
|
agentsHydrated: boolean;
|
||||||
@@ -755,7 +760,7 @@ function WorkspaceScreenContent({
|
|||||||
tabId: tab.tabId,
|
tabId: tab.tabId,
|
||||||
kind: "draft",
|
kind: "draft",
|
||||||
draftId: target.draftId,
|
draftId: target.draftId,
|
||||||
label: "New agent",
|
label: "Draft",
|
||||||
subtitle: "Draft",
|
subtitle: "Draft",
|
||||||
});
|
});
|
||||||
continue;
|
continue;
|
||||||
@@ -769,7 +774,7 @@ function WorkspaceScreenContent({
|
|||||||
kind: "agent",
|
kind: "agent",
|
||||||
agentId: target.agentId,
|
agentId: target.agentId,
|
||||||
provider,
|
provider,
|
||||||
label: agent?.title?.trim() || "Agent",
|
label: resolveWorkspaceAgentTabLabel(agent?.title),
|
||||||
subtitle: `${formatProviderLabel(provider)} agent`,
|
subtitle: `${formatProviderLabel(provider)} agent`,
|
||||||
});
|
});
|
||||||
continue;
|
continue;
|
||||||
@@ -868,13 +873,6 @@ function WorkspaceScreenContent({
|
|||||||
[tabs]
|
[tabs]
|
||||||
);
|
);
|
||||||
|
|
||||||
const activeAgent = useMemo(() => {
|
|
||||||
if (activeTab?.target.kind !== "agent") {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
return sessionAgents?.get(activeTab.target.agentId) ?? null;
|
|
||||||
}, [activeTab, sessionAgents]);
|
|
||||||
|
|
||||||
const activeTabLabel = useMemo(() => {
|
const activeTabLabel = useMemo(() => {
|
||||||
const active = tabs.find((tab) => tab.key === activeTabKey);
|
const active = tabs.find((tab) => tab.key === activeTabKey);
|
||||||
return active?.label ?? "Select tab";
|
return active?.label ?? "Select tab";
|
||||||
@@ -1210,15 +1208,6 @@ function WorkspaceScreenContent({
|
|||||||
]
|
]
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleOpenAgentChatView = useCallback(() => {
|
|
||||||
if (!activeAgent) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
router.push(
|
|
||||||
buildHostAgentDetailRoute(normalizedServerId, activeAgent.id) as any
|
|
||||||
);
|
|
||||||
}, [activeAgent, normalizedServerId, router]);
|
|
||||||
|
|
||||||
const renderContent = () => {
|
const renderContent = () => {
|
||||||
if (
|
if (
|
||||||
shouldRenderMissingWorkspaceDescriptor({
|
shouldRenderMissingWorkspaceDescriptor({
|
||||||
@@ -1385,32 +1374,6 @@ function WorkspaceScreenContent({
|
|||||||
)}
|
)}
|
||||||
</HeaderToggleButton>
|
</HeaderToggleButton>
|
||||||
|
|
||||||
{activeAgent ? (
|
|
||||||
<DropdownMenu>
|
|
||||||
<DropdownMenuTrigger
|
|
||||||
testID="workspace-agent-overflow-menu"
|
|
||||||
style={styles.menuButton}
|
|
||||||
>
|
|
||||||
<MoreVertical
|
|
||||||
size={isMobile ? theme.iconSize.lg : theme.iconSize.md}
|
|
||||||
color={theme.colors.foregroundMuted}
|
|
||||||
/>
|
|
||||||
</DropdownMenuTrigger>
|
|
||||||
<DropdownMenuContent
|
|
||||||
align="end"
|
|
||||||
width={DROPDOWN_WIDTH}
|
|
||||||
testID="workspace-agent-overflow-content"
|
|
||||||
>
|
|
||||||
<DropdownMenuItem
|
|
||||||
testID="workspace-agent-overflow-open-chat"
|
|
||||||
description="Open this agent with the full chat header"
|
|
||||||
onSelect={handleOpenAgentChatView}
|
|
||||||
>
|
|
||||||
Open chat view
|
|
||||||
</DropdownMenuItem>
|
|
||||||
</DropdownMenuContent>
|
|
||||||
</DropdownMenu>
|
|
||||||
) : null}
|
|
||||||
</View>
|
</View>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -0,0 +1,78 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
import {
|
||||||
|
decideLongPressMove,
|
||||||
|
shouldOpenContextMenuOnPressOut,
|
||||||
|
} from "./sidebar-gesture-arbitration";
|
||||||
|
|
||||||
|
describe("decideLongPressMove", () => {
|
||||||
|
it("keeps long press pending for small movement before long-press arm", () => {
|
||||||
|
expect(
|
||||||
|
decideLongPressMove({
|
||||||
|
longPressArmed: false,
|
||||||
|
didStartDrag: false,
|
||||||
|
startPoint: { x: 0, y: 0 },
|
||||||
|
currentPoint: { x: 3, y: 2 },
|
||||||
|
})
|
||||||
|
).toBe("none");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("cancels long press when movement exceeds cancel slop before arm", () => {
|
||||||
|
expect(
|
||||||
|
decideLongPressMove({
|
||||||
|
longPressArmed: false,
|
||||||
|
didStartDrag: false,
|
||||||
|
startPoint: { x: 0, y: 0 },
|
||||||
|
currentPoint: { x: 12, y: 0 },
|
||||||
|
})
|
||||||
|
).toBe("cancel_long_press");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("starts drag when movement exceeds drag slop after long-press arm", () => {
|
||||||
|
expect(
|
||||||
|
decideLongPressMove({
|
||||||
|
longPressArmed: true,
|
||||||
|
didStartDrag: false,
|
||||||
|
startPoint: { x: 0, y: 0 },
|
||||||
|
currentPoint: { x: 0, y: 9 },
|
||||||
|
})
|
||||||
|
).toBe("start_drag");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does nothing when drag already started", () => {
|
||||||
|
expect(
|
||||||
|
decideLongPressMove({
|
||||||
|
longPressArmed: true,
|
||||||
|
didStartDrag: true,
|
||||||
|
startPoint: { x: 0, y: 0 },
|
||||||
|
currentPoint: { x: 20, y: 20 },
|
||||||
|
})
|
||||||
|
).toBe("none");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("shouldOpenContextMenuOnPressOut", () => {
|
||||||
|
it("opens menu only when long-press is armed and drag did not start", () => {
|
||||||
|
expect(
|
||||||
|
shouldOpenContextMenuOnPressOut({
|
||||||
|
longPressArmed: true,
|
||||||
|
didStartDrag: false,
|
||||||
|
})
|
||||||
|
).toBe(true);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
shouldOpenContextMenuOnPressOut({
|
||||||
|
longPressArmed: false,
|
||||||
|
didStartDrag: false,
|
||||||
|
})
|
||||||
|
).toBe(false);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
shouldOpenContextMenuOnPressOut({
|
||||||
|
longPressArmed: true,
|
||||||
|
didStartDrag: true,
|
||||||
|
})
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
export type LongPressMoveDecision = "none" | "cancel_long_press" | "start_drag";
|
||||||
|
|
||||||
|
export function decideLongPressMove(input: {
|
||||||
|
longPressArmed: boolean;
|
||||||
|
didStartDrag: boolean;
|
||||||
|
startPoint: { x: number; y: number } | null;
|
||||||
|
currentPoint: { x: number; y: number };
|
||||||
|
cancelSlopPx?: number;
|
||||||
|
dragSlopPx?: number;
|
||||||
|
}): LongPressMoveDecision {
|
||||||
|
const cancelSlopPx = input.cancelSlopPx ?? 10;
|
||||||
|
const dragSlopPx = input.dragSlopPx ?? 8;
|
||||||
|
|
||||||
|
if (!input.startPoint) {
|
||||||
|
return "none";
|
||||||
|
}
|
||||||
|
if (input.didStartDrag) {
|
||||||
|
return "none";
|
||||||
|
}
|
||||||
|
|
||||||
|
const dx = input.currentPoint.x - input.startPoint.x;
|
||||||
|
const dy = input.currentPoint.y - input.startPoint.y;
|
||||||
|
const distance = Math.sqrt(dx * dx + dy * dy);
|
||||||
|
|
||||||
|
if (!input.longPressArmed) {
|
||||||
|
return distance > cancelSlopPx ? "cancel_long_press" : "none";
|
||||||
|
}
|
||||||
|
return distance > dragSlopPx ? "start_drag" : "none";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function shouldOpenContextMenuOnPressOut(input: {
|
||||||
|
longPressArmed: boolean;
|
||||||
|
didStartDrag: boolean;
|
||||||
|
}): boolean {
|
||||||
|
return input.longPressArmed && !input.didStartDrag;
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
import { hasVisibleOrderChanged, mergeWithRemainder } from "./sidebar-reorder";
|
||||||
|
|
||||||
|
describe("hasVisibleOrderChanged", () => {
|
||||||
|
it("returns false when visible order is unchanged", () => {
|
||||||
|
expect(
|
||||||
|
hasVisibleOrderChanged({
|
||||||
|
currentOrder: ["a", "b", "c", "d"],
|
||||||
|
reorderedVisibleKeys: ["a", "b", "c"],
|
||||||
|
})
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns true when visible items are reordered", () => {
|
||||||
|
expect(
|
||||||
|
hasVisibleOrderChanged({
|
||||||
|
currentOrder: ["a", "b", "c", "d"],
|
||||||
|
reorderedVisibleKeys: ["b", "a", "c"],
|
||||||
|
})
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns true when a visible key is missing from current order", () => {
|
||||||
|
expect(
|
||||||
|
hasVisibleOrderChanged({
|
||||||
|
currentOrder: ["a", "b"],
|
||||||
|
reorderedVisibleKeys: ["a", "c"],
|
||||||
|
})
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("mergeWithRemainder", () => {
|
||||||
|
it("appends non-visible stored keys after reordered visible keys", () => {
|
||||||
|
expect(
|
||||||
|
mergeWithRemainder({
|
||||||
|
currentOrder: ["a", "x", "b", "y"],
|
||||||
|
reorderedVisibleKeys: ["b", "a"],
|
||||||
|
})
|
||||||
|
).toEqual(["b", "a", "x", "y"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps unknown current keys when no visible keys are reordered", () => {
|
||||||
|
expect(
|
||||||
|
mergeWithRemainder({
|
||||||
|
currentOrder: ["stale", "hidden"],
|
||||||
|
reorderedVisibleKeys: [],
|
||||||
|
})
|
||||||
|
).toEqual(["stale", "hidden"]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
export function mergeWithRemainder(input: {
|
||||||
|
currentOrder: string[]
|
||||||
|
reorderedVisibleKeys: string[]
|
||||||
|
}): string[] {
|
||||||
|
const reorderedSet = new Set(input.reorderedVisibleKeys)
|
||||||
|
const remainder = input.currentOrder.filter((key) => !reorderedSet.has(key))
|
||||||
|
return [...input.reorderedVisibleKeys, ...remainder]
|
||||||
|
}
|
||||||
|
|
||||||
|
export function hasVisibleOrderChanged(input: {
|
||||||
|
currentOrder: string[]
|
||||||
|
reorderedVisibleKeys: string[]
|
||||||
|
}): boolean {
|
||||||
|
const visibleSet = new Set(input.reorderedVisibleKeys)
|
||||||
|
const currentVisible = input.currentOrder.filter((key) => visibleSet.has(key))
|
||||||
|
if (currentVisible.length !== input.reorderedVisibleKeys.length) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return input.reorderedVisibleKeys.some((key, index) => currentVisible[index] !== key)
|
||||||
|
}
|
||||||
|
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import type { SidebarProjectEntry, SidebarWorkspaceEntry } from "@/hooks/use-sidebar-workspaces-list";
|
import type { SidebarProjectEntry, SidebarWorkspaceEntry } from "@/hooks/use-sidebar-workspaces-list";
|
||||||
|
|
||||||
import { buildSidebarWorkspaceViewModel } from "./sidebar-shortcuts";
|
import { buildSidebarShortcutModel } from "./sidebar-shortcuts";
|
||||||
|
|
||||||
function workspace(serverId: string, cwd: string): SidebarWorkspaceEntry {
|
function workspace(serverId: string, cwd: string): SidebarWorkspaceEntry {
|
||||||
return {
|
return {
|
||||||
@@ -27,20 +27,18 @@ function project(projectKey: string, workspaces: SidebarWorkspaceEntry[]): Sideb
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
describe("buildSidebarWorkspaceViewModel", () => {
|
describe("buildSidebarShortcutModel", () => {
|
||||||
it("builds visible rows and shortcut targets in visual order", () => {
|
it("builds shortcut targets in visual order and excludes collapsed projects", () => {
|
||||||
const projects = [
|
const projects = [
|
||||||
project("p1", [workspace("s1", "/repo/main"), workspace("s1", "/repo/feat-a")]),
|
project("p1", [workspace("s1", "/repo/main"), workspace("s1", "/repo/feat-a")]),
|
||||||
project("p2", [workspace("s1", "/repo2/main")]),
|
project("p2", [workspace("s1", "/repo2/main")]),
|
||||||
];
|
];
|
||||||
|
|
||||||
const model = buildSidebarWorkspaceViewModel({
|
const model = buildSidebarShortcutModel({
|
||||||
projects,
|
projects,
|
||||||
collapsedProjectKeys: new Set<string>(["p2"]),
|
collapsedProjectKeys: new Set<string>(["p2"]),
|
||||||
getProjectDisplayName: (entry) => entry.projectName,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(model.rows.map((row) => row.kind)).toEqual(["project", "workspace", "workspace", "project"]);
|
|
||||||
expect(model.shortcutTargets).toEqual([
|
expect(model.shortcutTargets).toEqual([
|
||||||
{ serverId: "s1", workspaceId: "/repo/main" },
|
{ serverId: "s1", workspaceId: "/repo/main" },
|
||||||
{ serverId: "s1", workspaceId: "/repo/feat-a" },
|
{ serverId: "s1", workspaceId: "/repo/feat-a" },
|
||||||
@@ -54,10 +52,9 @@ describe("buildSidebarWorkspaceViewModel", () => {
|
|||||||
const workspaces = Array.from({ length: 20 }, (_, index) => workspace("s", `/repo/w${index + 1}`));
|
const workspaces = Array.from({ length: 20 }, (_, index) => workspace("s", `/repo/w${index + 1}`));
|
||||||
const projects = [project("p", workspaces)];
|
const projects = [project("p", workspaces)];
|
||||||
|
|
||||||
const model = buildSidebarWorkspaceViewModel({
|
const model = buildSidebarShortcutModel({
|
||||||
projects,
|
projects,
|
||||||
collapsedProjectKeys: new Set<string>(),
|
collapsedProjectKeys: new Set<string>(),
|
||||||
getProjectDisplayName: (entry) => entry.projectName,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(model.shortcutTargets).toHaveLength(9);
|
expect(model.shortcutTargets).toHaveLength(9);
|
||||||
|
|||||||
@@ -8,46 +8,21 @@ export interface SidebarShortcutWorkspaceTarget {
|
|||||||
workspaceId: string
|
workspaceId: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export type SidebarWorkspaceTreeRow =
|
export interface SidebarShortcutModel {
|
||||||
| {
|
|
||||||
kind: 'project'
|
|
||||||
rowKey: string
|
|
||||||
project: SidebarProjectEntry
|
|
||||||
displayName: string
|
|
||||||
}
|
|
||||||
| {
|
|
||||||
kind: 'workspace'
|
|
||||||
rowKey: string
|
|
||||||
projectKey: string
|
|
||||||
workspace: SidebarWorkspaceEntry
|
|
||||||
shortcutNumber: number | null
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface SidebarWorkspaceViewModel {
|
|
||||||
rows: SidebarWorkspaceTreeRow[]
|
|
||||||
shortcutTargets: SidebarShortcutWorkspaceTarget[]
|
shortcutTargets: SidebarShortcutWorkspaceTarget[]
|
||||||
shortcutIndexByWorkspaceKey: Map<string, number>
|
shortcutIndexByWorkspaceKey: Map<string, number>
|
||||||
}
|
}
|
||||||
|
|
||||||
export function buildSidebarWorkspaceViewModel(input: {
|
export function buildSidebarShortcutModel(input: {
|
||||||
projects: SidebarProjectEntry[]
|
projects: SidebarProjectEntry[]
|
||||||
collapsedProjectKeys: ReadonlySet<string>
|
collapsedProjectKeys: ReadonlySet<string>
|
||||||
getProjectDisplayName: (project: SidebarProjectEntry) => string
|
|
||||||
shortcutLimit?: number
|
shortcutLimit?: number
|
||||||
}): SidebarWorkspaceViewModel {
|
}): SidebarShortcutModel {
|
||||||
const maxShortcuts = Math.max(0, Math.floor(input.shortcutLimit ?? 9))
|
const maxShortcuts = Math.max(0, Math.floor(input.shortcutLimit ?? 9))
|
||||||
const rows: SidebarWorkspaceTreeRow[] = []
|
|
||||||
const shortcutTargets: SidebarShortcutWorkspaceTarget[] = []
|
const shortcutTargets: SidebarShortcutWorkspaceTarget[] = []
|
||||||
const shortcutIndexByWorkspaceKey = new Map<string, number>()
|
const shortcutIndexByWorkspaceKey = new Map<string, number>()
|
||||||
|
|
||||||
for (const project of input.projects) {
|
for (const project of input.projects) {
|
||||||
rows.push({
|
|
||||||
kind: 'project',
|
|
||||||
rowKey: `project:${project.projectKey}`,
|
|
||||||
project,
|
|
||||||
displayName: input.getProjectDisplayName(project),
|
|
||||||
})
|
|
||||||
|
|
||||||
if (input.collapsedProjectKeys.has(project.projectKey)) {
|
if (input.collapsedProjectKeys.has(project.projectKey)) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
@@ -62,16 +37,8 @@ export function buildSidebarWorkspaceViewModel(input: {
|
|||||||
})
|
})
|
||||||
shortcutIndexByWorkspaceKey.set(workspace.workspaceKey, shortcutNumber)
|
shortcutIndexByWorkspaceKey.set(workspace.workspaceKey, shortcutNumber)
|
||||||
}
|
}
|
||||||
|
|
||||||
rows.push({
|
|
||||||
kind: 'workspace',
|
|
||||||
rowKey: `workspace:${project.projectKey}:${workspace.workspaceKey}`,
|
|
||||||
projectKey: project.projectKey,
|
|
||||||
workspace,
|
|
||||||
shortcutNumber,
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return { rows, shortcutTargets, shortcutIndexByWorkspaceKey }
|
return { shortcutTargets, shortcutIndexByWorkspaceKey }
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user